KibanaErrorBoundary initial implementation (#168754)

## Summary

* Meta issue: https://github.com/elastic/kibana/issues/166584
* This PR implements tasks in:
https://github.com/elastic/kibana/issues/167159
* [Technical doc [Elastic
internal]](https://docs.google.com/document/d/1kVD3T08AzLuvRMnFrXzWd6rTQWZDFfjqmOMCoXRI-14/edit)

This PR creates the `ErrorBoundary` component and its provider for
services. It implements the wrapper around a few management apps owned
by Appex-SharedUX.

### Screenshots

Updated 2023-10-18

**Server upgrade scenario:** In this case, the caught error is known to
be recoverable via window refresh:
* <img width="1413" alt="image"
src="7f34fbab-0e67-4c67-a4a1-989464d5b0d0">

**Unknown/Custom error:** In this case, the error is something outside
of known cases where the fix is to refresh:
* <img width="1413" alt="image"
src="7c39b5df-d4da-4e33-aeca-9ea447010762">

### Testing

1. Use a script proxy in between the browser and the Kibana server.
    * Try **https://github.com/tsullivan/simple-node-proxy**
* or
**https://chrome.google.com/webstore/detail/tweak-mock-and-modify-htt/feahianecghpnipmhphmfgmpdodhcapi**.
2. Script the proxy to send 404 responses for the Reporting plugin
bundle, and for a bundle of some Management app.
3. Try the Share > CSV menu in Discover. It should be blocked, and
handled with a toast message. Buttons in the toast should work.
4. Try the SharedUX management apps that use the wrapper. It should be
blocked, and handled with an EuiCallout. Refresh button and EuiAccordion
should work.

### Checklist
- [x] Ensure the package code is delivered to the browser in the initial
loading of the page (c2559e83d2)
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Tiago Costa <tiago.costa@elastic.co>
This commit is contained in:
Tim Sullivan 2023-10-23 07:47:30 -07:00 committed by GitHub
parent ce931e2820
commit 53c83e789b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1474 additions and 427 deletions

1
.github/CODEOWNERS vendored
View file

@ -681,6 +681,7 @@ packages/shared-ux/card/no_data/impl @elastic/appex-sharedux
packages/shared-ux/card/no_data/mocks @elastic/appex-sharedux
packages/shared-ux/card/no_data/types @elastic/appex-sharedux
packages/shared-ux/chrome/navigation @elastic/appex-sharedux
packages/shared-ux/error_boundary @elastic/appex-sharedux
packages/shared-ux/file/context @elastic/appex-sharedux
packages/shared-ux/file/image/impl @elastic/appex-sharedux
packages/shared-ux/file/image/mocks @elastic/appex-sharedux

View file

@ -684,6 +684,7 @@
"@kbn/shared-ux-card-no-data-mocks": "link:packages/shared-ux/card/no_data/mocks",
"@kbn/shared-ux-card-no-data-types": "link:packages/shared-ux/card/no_data/types",
"@kbn/shared-ux-chrome-navigation": "link:packages/shared-ux/chrome/navigation",
"@kbn/shared-ux-error-boundary": "link:packages/shared-ux/error_boundary",
"@kbn/shared-ux-file-context": "link:packages/shared-ux/file/context",
"@kbn/shared-ux-file-image": "link:packages/shared-ux/file/image/impl",
"@kbn/shared-ux-file-image-mocks": "link:packages/shared-ux/file/image/mocks",

View file

@ -51,7 +51,27 @@ Array [
"calls": Array [
Array [
Object {
"children": <EuiErrorBoundary>
"children": <KibanaErrorBoundaryProvider>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
>
<MountWrapper
className="kbnOverlayMountWrapper"
mount={[Function]}
/>
</EuiFlyout>
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>,
},
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": <KibanaErrorBoundaryProvider>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
>
@ -60,24 +80,8 @@ Array [
mount={[Function]}
/>
</EuiFlyout>
</EuiErrorBoundary>,
},
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": <EuiErrorBoundary>
<EuiFlyout
onClose={[Function]}
>
<MountWrapper
className="kbnOverlayMountWrapper"
mount={[Function]}
/>
</EuiFlyout>
</EuiErrorBoundary>,
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>,
},
],
},
@ -110,7 +114,27 @@ Array [
"calls": Array [
Array [
Object {
"children": <EuiErrorBoundary>
"children": <KibanaErrorBoundaryProvider>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
>
<MountWrapper
className="kbnOverlayMountWrapper"
mount={[Function]}
/>
</EuiFlyout>
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>,
},
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": <KibanaErrorBoundaryProvider>
<KibanaErrorBoundary>
<EuiFlyout
onClose={[Function]}
>
@ -119,24 +143,8 @@ Array [
mount={[Function]}
/>
</EuiFlyout>
</EuiErrorBoundary>,
},
Object {},
],
],
"results": Array [
Object {
"type": "return",
"value": <EuiErrorBoundary>
<EuiFlyout
onClose={[Function]}
>
<MountWrapper
className="kbnOverlayMountWrapper"
mount={[Function]}
/>
</EuiFlyout>
</EuiErrorBoundary>,
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>,
},
],
},

View file

@ -30,7 +30,7 @@ pageLoadAssetSize:
data: 454087
dataViewEditor: 28082
dataViewFieldEditor: 27000
dataViewManagement: 5000
dataViewManagement: 5100
dataViews: 48300
dataVisualizer: 27530
devTools: 38637

View file

@ -32,6 +32,7 @@ webpack_cli(
"//packages/kbn-safer-lodash-set",
"//packages/kbn-peggy",
"//packages/kbn-peggy-loader",
"//packages/shared-ux/error_boundary",
"//packages/kbn-rison",
],
output_dir = True,

View file

@ -91,6 +91,7 @@ const externals = {
'@kbn/es-query': '__kbnSharedDeps__.KbnEsQuery',
'@kbn/std': '__kbnSharedDeps__.KbnStd',
'@kbn/safer-lodash-set': '__kbnSharedDeps__.SaferLodashSet',
'@kbn/shared-ux-error-boundary': '__kbnSharedDeps__.KbnSharedUxErrorBoundary',
'@kbn/rison': '__kbnSharedDeps__.KbnRison',
history: '__kbnSharedDeps__.History',
classnames: '__kbnSharedDeps__.Classnames',

View file

@ -66,6 +66,8 @@ export const KbnAnalytics = require('@kbn/analytics');
export const KbnEsQuery = require('@kbn/es-query');
export const KbnStd = require('@kbn/std');
export const SaferLodashSet = require('@kbn/safer-lodash-set');
export const KbnSharedUxErrorBoundary = require('@kbn/shared-ux-error-boundary');
export const KbnRison = require('@kbn/rison');
export const History = require('history');
export const Classnames = require('classnames');

View file

@ -26,6 +26,7 @@
"@kbn/rison",
"@kbn/std",
"@kbn/safer-lodash-set",
"@kbn/repo-info"
"@kbn/repo-info",
"@kbn/shared-ux-error-boundary"
]
}

View file

@ -12,7 +12,7 @@ import {
KibanaRootContextProvider,
type KibanaRootContextProviderProps,
} from '@kbn/react-kibana-context-root';
import { EuiErrorBoundary } from '@elastic/eui';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary';
/** Props for the KibanaContextProvider */
export type KibanaRenderContextProviderProps = Omit<KibanaRootContextProviderProps, 'globalStyles'>;
@ -27,7 +27,9 @@ export const KibanaRenderContextProvider: FC<KibanaRenderContextProviderProps> =
}) => {
return (
<KibanaRootContextProvider globalStyles={false} {...props}>
<EuiErrorBoundary>{children}</EuiErrorBoundary>
<KibanaErrorBoundaryProvider>
<KibanaErrorBoundary>{children}</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>
</KibanaRootContextProvider>
);
};

View file

@ -17,5 +17,6 @@
],
"kbn_references": [
"@kbn/react-kibana-context-root",
"@kbn/shared-ux-error-boundary",
]
}

View file

@ -0,0 +1,35 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
SRCS = glob(
[
"**/*.ts",
"**/*.tsx",
],
exclude = [
"**/test_helpers.ts",
"**/*.config.js",
"**/*.mock.*",
"**/*.test.*",
"**/*.stories.*",
"**/__snapshots__/**",
"**/integration_tests/**",
"**/mocks/**",
"**/scripts/**",
"**/storybook/**",
"**/test_fixtures/**",
"**/test_helpers/**",
],
)
BUNDLER_DEPS = [
"@npm//react",
"@npm//tslib",
]
js_library(
name = "error_boundary",
package_name = "@kbn/shared-ux-error-boundary",
srcs = ["package.json"] + SRCS,
deps = BUNDLER_DEPS,
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,16 @@
---
id: sharedUX/KibanaErrorBoundary
slug: /shared-ux/error_boundary/kibana_error_boundary
title: Kibana Error Boundary
description: Container to catch errors thrown by child component
tags: ['shared-ux', 'component', 'error', 'error_boundary']
date: 2023-10-03
---
## Description
## API
## EUI Promotion Status
This component is specialized for error messages internal to Kibana and is not intended for promotion to EUI.

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { KibanaErrorBoundary } from './src/ui/error_boundary';
export { KibanaErrorBoundaryProvider } from './src/services/error_boundary_services';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/shared-ux/error_boundary'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/shared-ux-error-boundary",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { BadComponent } from './src/bad_component';
export { ChunkLoadErrorComponent } from './src/chunk_load_error_component';
export { getServicesMock } from './src/jest';
export { KibanaErrorBoundaryStorybookMock } from './src/storybook';

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiButton } from '@elastic/eui';
import { action } from '@storybook/addon-actions';
import React, { useState } from 'react';
export const BadComponent = () => {
const [hasError, setHasError] = useState(false);
if (hasError) {
throw new Error('This is an error to show the test user!'); // custom error
}
const clickedForError = action('clicked for error');
const handleClick = () => {
clickedForError();
setHasError(true);
};
return (
<EuiButton onClick={handleClick} data-test-subj="clickForErrorBtn">
Click for error
</EuiButton>
);
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiButton } from '@elastic/eui';
import { action } from '@storybook/addon-actions';
import React, { useState } from 'react';
export const ChunkLoadErrorComponent = () => {
const [hasError, setHasError] = useState(false);
if (hasError) {
const chunkError = new Error('Could not load chunk');
chunkError.name = 'ChunkLoadError'; // specific error known to be recoverable with a click of a refresh button
throw chunkError;
}
const clickedForError = action('clicked for error');
const handleClick = () => {
clickedForError();
setHasError(true);
};
return (
<EuiButton onClick={handleClick} fill={true} data-test-subj="clickForErrorBtn">
Click for error
</EuiButton>
);
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaErrorService } from '../../src/services/error_service';
import { KibanaErrorBoundaryServices } from '../../types';
export const getServicesMock = (): KibanaErrorBoundaryServices => {
return {
onClickRefresh: jest.fn().mockResolvedValue(undefined),
errorService: new KibanaErrorService(),
};
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock';
import { action } from '@storybook/addon-actions';
import { KibanaErrorService } from '../../src/services/error_service';
import { KibanaErrorBoundaryServices } from '../../types';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Params {}
export class KibanaErrorBoundaryStorybookMock extends AbstractStorybookMock<
{},
KibanaErrorBoundaryServices
> {
propArguments = {};
serviceArguments = {};
dependencies = [];
getServices(params: Params = {}): KibanaErrorBoundaryServices {
const reloadWindowAction = action('Reload window');
const onClickRefresh = () => {
reloadWindowAction();
};
return {
...params,
onClickRefresh,
errorService: new KibanaErrorService(),
};
}
getProps(params: Params) {
return params;
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import {
EuiCollapsibleNavBeta,
EuiHeader,
EuiHeaderSection,
EuiLink,
EuiPageTemplate,
} from '@elastic/eui';
export const Template: FC = ({ children }) => {
return (
<>
<EuiHeader position="fixed">
<EuiHeaderSection>
<EuiCollapsibleNavBeta />
</EuiHeaderSection>
</EuiHeader>
<EuiPageTemplate>
<EuiPageTemplate.Header pageTitle="Welcome to my page" />
<EuiPageTemplate.Section grow={true}>{children}</EuiPageTemplate.Section>
<EuiPageTemplate.Section grow={false}>
<EuiLink>Contact us</EuiLink>
</EuiPageTemplate.Section>
</EuiPageTemplate>
</>
);
};

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/shared-ux-error-boundary",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, useContext, useMemo } from 'react';
import { KibanaErrorBoundaryServices } from '../../types';
import { KibanaErrorService } from './error_service';
const Context = React.createContext<KibanaErrorBoundaryServices | null>(null);
/**
* A Context Provider for Jest and Storybooks
*/
export const KibanaErrorBoundaryDepsProvider: FC<KibanaErrorBoundaryServices> = ({
children,
onClickRefresh,
errorService,
}) => {
return <Context.Provider value={{ onClickRefresh, errorService }}>{children}</Context.Provider>;
};
/**
* Kibana-specific Provider that maps dependencies to services.
*/
export const KibanaErrorBoundaryProvider: FC = ({ children }) => {
const value: KibanaErrorBoundaryServices = useMemo(
() => ({
onClickRefresh: () => window.location.reload(),
errorService: new KibanaErrorService(),
}),
[]
);
return <Context.Provider value={value}>{children}</Context.Provider>;
};
/**
* React hook for accessing pre-wired services.
*/
export function useErrorBoundary(): KibanaErrorBoundaryServices {
const context = useContext(Context);
if (!context) {
throw new Error(
'Kibana Error Boundary Context is missing. Ensure your component or React root is wrapped with Kibana Error Boundary Context.'
);
}
return context;
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaErrorService } from './error_service';
describe('KibanaErrorBoundary KibanaErrorService', () => {
const service = new KibanaErrorService();
it('construction', () => {
expect(service).toHaveProperty('registerError');
});
it('decorates fatal error object', () => {
const testFatal = new Error('This is an unrecognized and fatal error');
const serviceError = service.registerError(testFatal, {});
expect(serviceError.isFatal).toBe(true);
});
it('decorates recoverable error object', () => {
const testRecoverable = new Error('Could not load chunk blah blah');
testRecoverable.name = 'ChunkLoadError';
const serviceError = service.registerError(testRecoverable, {});
expect(serviceError.isFatal).toBe(false);
});
it('derives component name', () => {
const testFatal = new Error('This is an unrecognized and fatal error');
const errorInfo = {
componentStack: `
at BadComponent (http://localhost:9001/main.iframe.bundle.js:11616:73)
at ErrorBoundaryInternal (http://localhost:9001/main.iframe.bundle.js:12232:81)
at KibanaErrorBoundary (http://localhost:9001/main.iframe.bundle.js:12295:116)
at KibanaErrorBoundaryDepsProvider (http://localhost:9001/main.iframe.bundle.js:11879:23)
at div
at http://localhost:9001/kbn-ui-shared-deps-npm.dll.js:164499:73
at section
at http://localhost:9001/kbn-ui-shared-deps-npm.dll.js`,
};
const serviceError = service.registerError(testFatal, errorInfo);
expect(serviceError.name).toBe('BadComponent');
});
});

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
const MATCH_CHUNK_LOADERROR = /ChunkLoadError/;
interface ErrorServiceError {
error: Error;
errorInfo?: Partial<React.ErrorInfo> | null;
name: string | null;
isFatal: boolean;
}
/**
* Kibana Error Boundary Services: Error Service
* Each Error Boundary tracks an instance of this class
* @internal
*/
export class KibanaErrorService {
/**
* Determines if the error fallback UI should appear as an apologetic but promising "Refresh" button,
* or treated with "danger" coloring and include a detailed error message.
*/
private getIsFatal(error: Error) {
const isChunkLoadError = MATCH_CHUNK_LOADERROR.test(error.name);
return !isChunkLoadError; // "ChunkLoadError" is recoverable by refreshing the page
}
/**
* Derive the name of the component that threw the error
*/
private getErrorComponentName(errorInfo: Partial<React.ErrorInfo> | null) {
let errorComponentName: string | null = null;
const stackLines = errorInfo?.componentStack?.split('\n');
const errorIndicator = /^ at (\S+).*/;
if (stackLines) {
let i = 0;
while (i < stackLines.length - 1) {
// scan the stack trace text
if (stackLines[i].match(errorIndicator)) {
// extract the name of the bad component
errorComponentName = stackLines[i].replace(errorIndicator, '$1');
if (errorComponentName) {
break;
}
}
i++;
}
}
return errorComponentName;
}
/**
* Creates a decorated error object
* TODO: capture telemetry
*/
public registerError(
error: Error,
errorInfo: Partial<React.ErrorInfo> | null
): ErrorServiceError {
const isFatal = this.getIsFatal(error);
const name = this.getErrorComponentName(errorInfo);
return {
error,
errorInfo,
isFatal,
name,
};
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Meta, Story } from '@storybook/react';
import React from 'react';
import { Template } from '../../mocks/src/storybook_template';
import { BadComponent, KibanaErrorBoundaryStorybookMock } from '../../mocks';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
import { KibanaErrorBoundary } from './error_boundary';
import mdx from '../../README.mdx';
const storybookMock = new KibanaErrorBoundaryStorybookMock();
export default {
title: 'Errors/Fatal Errors',
description:
'This is the Kibana Error Boundary. Use this to put a boundary around React components that may throw errors when rendering. It will intercept the error and determine if it is fatal or recoverable.',
parameters: {
docs: {
page: mdx,
},
},
} as Meta;
export const ErrorInCallout: Story = () => {
const services = storybookMock.getServices();
return (
<Template>
<KibanaErrorBoundaryDepsProvider {...services}>
<KibanaErrorBoundary>
<BadComponent />
</KibanaErrorBoundary>
</KibanaErrorBoundaryDepsProvider>
</Template>
);
};

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Meta, Story } from '@storybook/react';
import React from 'react';
import { Template } from '../../mocks/src/storybook_template';
import { ChunkLoadErrorComponent, KibanaErrorBoundaryStorybookMock } from '../../mocks';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
import { KibanaErrorBoundary } from './error_boundary';
import mdx from '../../README.mdx';
const storybookMock = new KibanaErrorBoundaryStorybookMock();
export default {
title: 'Errors/Recoverable Errors',
description:
'This is the Kibana Error Boundary.' +
' Use this to put a boundary around React components that may throw errors when rendering.' +
' It will intercept the error and determine if it is fatal or recoverable.',
parameters: {
docs: {
page: mdx,
},
},
} as Meta;
export const ErrorInCallout: Story = () => {
const services = storybookMock.getServices();
return (
<Template>
<KibanaErrorBoundaryDepsProvider {...services}>
<KibanaErrorBoundary>
<ChunkLoadErrorComponent />
</KibanaErrorBoundary>
</KibanaErrorBoundaryDepsProvider>
</Template>
);
};

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { render } from '@testing-library/react';
import React, { FC } from 'react';
import { KibanaErrorBoundary } from '../..';
import { BadComponent, ChunkLoadErrorComponent, getServicesMock } from '../../mocks';
import { KibanaErrorBoundaryServices } from '../../types';
import { errorMessageStrings as strings } from './message_strings';
import { KibanaErrorBoundaryDepsProvider } from '../services/error_boundary_services';
describe('<KibanaErrorBoundary>', () => {
let services: KibanaErrorBoundaryServices;
beforeEach(() => {
services = getServicesMock();
});
const Template: FC = ({ children }) => {
return (
<KibanaErrorBoundaryDepsProvider {...services}>
<KibanaErrorBoundary>{children}</KibanaErrorBoundary>
</KibanaErrorBoundaryDepsProvider>
);
};
it('allow children to render when there is no error', () => {
const inputText = 'Hello, beautiful world.';
const res = render(<Template>{inputText}</Template>);
expect(res.getByText(inputText)).toBeInTheDocument();
});
it('renders a "soft" callout when an unknown error is caught', async () => {
const reloadSpy = jest.spyOn(services, 'onClickRefresh');
const { findByTestId, findByText } = render(
<Template>
<ChunkLoadErrorComponent />
</Template>
);
(await findByTestId('clickForErrorBtn')).click();
expect(await findByText(strings.recoverable.callout.title())).toBeVisible();
expect(await findByText(strings.recoverable.callout.pageReloadButton())).toBeVisible();
(await findByTestId('recoverablePromptReloadBtn')).click();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
it('renders a fatal callout when an unknown error is caught', async () => {
const reloadSpy = jest.spyOn(services, 'onClickRefresh');
const { findByTestId, findByText } = render(
<Template>
<BadComponent />
</Template>
);
(await findByTestId('clickForErrorBtn')).click();
expect(await findByText(strings.fatal.callout.title())).toBeVisible();
expect(await findByText(strings.fatal.callout.body())).toBeVisible();
expect(await findByText(strings.fatal.callout.showDetailsButton())).toBeVisible();
expect(await findByText(strings.fatal.callout.pageReloadButton())).toBeVisible();
(await findByTestId('fatalPromptReloadBtn')).click();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { KibanaErrorBoundaryServices } from '../../types';
import { useErrorBoundary } from '../services/error_boundary_services';
import { FatalPrompt, RecoverablePrompt } from './message_components';
interface ErrorBoundaryState {
error: null | Error;
errorInfo: null | Partial<React.ErrorInfo>;
componentName: null | string;
isFatal: null | boolean;
}
interface ErrorBoundaryProps {
children?: React.ReactNode;
}
interface ServiceContext {
services: KibanaErrorBoundaryServices;
}
class ErrorBoundaryInternal extends React.Component<
ErrorBoundaryProps & ServiceContext,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps & ServiceContext) {
super(props);
this.state = {
error: null,
errorInfo: null,
componentName: null,
isFatal: null,
};
}
componentDidCatch(error: Error, errorInfo: Partial<React.ErrorInfo>) {
const { name, isFatal } = this.props.services.errorService.registerError(error, errorInfo);
this.setState(() => {
return { error, errorInfo, componentName: name, isFatal };
});
}
render() {
if (this.state.error != null) {
const { error, errorInfo, componentName, isFatal } = this.state;
if (isFatal) {
return (
<FatalPrompt
error={error}
errorInfo={errorInfo}
name={componentName}
onClickRefresh={this.props.services.onClickRefresh}
/>
);
} else {
return (
<RecoverablePrompt
error={error}
errorInfo={errorInfo}
name={componentName}
onClickRefresh={this.props.services.onClickRefresh}
/>
);
}
}
// not in error state
return this.props.children;
}
}
/**
* Implementation of Kibana Error Boundary
* @param {ErrorBoundaryProps} props - ErrorBoundaryProps
* @public
*/
export const KibanaErrorBoundary = (props: ErrorBoundaryProps) => {
const services = useErrorBoundary();
return <ErrorBoundaryInternal {...props} services={services} />;
};

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiCodeBlock,
EuiEmptyPrompt,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiLink,
EuiTitle,
useGeneratedHtmlId,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiCopy,
} from '@elastic/eui';
import { errorMessageStrings as strings } from './message_strings';
export interface ErrorCalloutProps {
error: Error;
errorInfo: Partial<React.ErrorInfo> | null;
name: string | null;
onClickRefresh: () => void;
}
const CodePanel: React.FC<ErrorCalloutProps & { onClose: () => void }> = (props) => {
const { error, errorInfo, name: errorComponentName, onClose } = props;
const simpleFlyoutTitleId = useGeneratedHtmlId({
prefix: 'simpleFlyoutTitle',
});
const errorMessage = errorComponentName
? strings.fatal.callout.details.componentName(errorComponentName)
: error.message;
const errorTrace = errorInfo?.componentStack ?? error.stack ?? error.toString();
return (
<EuiFlyout onClose={onClose} aria-labelledby={simpleFlyoutTitleId} paddingSize="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{strings.fatal.callout.details.title()}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiCodeBlock>
<p>{errorMessage}</p>
<p>{errorTrace}</p>
</EuiCodeBlock>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} flush="left">
{strings.fatal.callout.details.closeButton()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={errorMessage + '\n\n' + errorTrace}>
{(copy) => (
<EuiButton onClick={copy} fill iconType="copyClipboard">
{strings.fatal.callout.details.copyToClipboardButton()}
</EuiButton>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};
export const FatalPrompt: React.FC<ErrorCalloutProps> = (props) => {
const { onClickRefresh } = props;
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
return (
<EuiEmptyPrompt
title={<h2>{strings.fatal.callout.title()}</h2>}
color="danger"
iconType="error"
body={
<>
<p>{strings.fatal.callout.body()}</p>
<p>
<EuiButton
color="danger"
iconType="refresh"
fill={true}
onClick={onClickRefresh}
data-test-subj="fatalPromptReloadBtn"
>
{strings.fatal.callout.pageReloadButton()}
</EuiButton>
</p>
<p>
<EuiLink color="danger" onClick={() => setIsFlyoutVisible(true)}>
{strings.fatal.callout.showDetailsButton()}
</EuiLink>
{isFlyoutVisible ? (
<CodePanel {...props} onClose={() => setIsFlyoutVisible(false)} />
) : null}
</p>
</>
}
/>
);
};
export const RecoverablePrompt = (props: ErrorCalloutProps) => {
const { onClickRefresh } = props;
return (
<EuiEmptyPrompt
iconType="warning"
title={<h2>{strings.recoverable.callout.title()}</h2>}
body={<p>{strings.recoverable.callout.body()}</p>}
color="warning"
actions={
<EuiButton
color="warning"
iconType="refresh"
fill={true}
onClick={onClickRefresh}
data-test-subj="recoverablePromptReloadBtn"
>
{strings.recoverable.callout.pageReloadButton()}
</EuiButton>
}
/>
);
};

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const errorMessageStrings = {
fatal: {
callout: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.title', {
defaultMessage: 'Unable to load page',
}),
body: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.body', {
defaultMessage: 'Try refreshing the page to resolve the issue.',
}),
showDetailsButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.detailButton', {
defaultMessage: 'Show details',
}),
details: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.title', {
defaultMessage: 'Error details',
}),
componentName: (errorComponentName: string) =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details', {
defaultMessage: 'An error occurred in {name}:',
values: { name: errorComponentName },
}),
closeButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.close', {
defaultMessage: 'Close',
}),
copyToClipboardButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details.copyToClipboard', {
defaultMessage: 'Copy error to clipboard',
}),
},
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
},
},
recoverable: {
callout: {
title: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.title', {
defaultMessage: 'Refresh the page',
}),
body: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.body', {
defaultMessage: 'A refresh fixes problems caused by upgrades or being offline.',
}),
pageReloadButton: () =>
i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.pageReloadButton', {
defaultMessage: 'Refresh page',
}),
},
},
};

View file

@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"react",
"@emotion/react/types/css-prop",
"@testing-library/jest-dom",
"@testing-library/react",
"@kbn/ambient-ui-types"
]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/shared-ux-storybook-mock",
"@kbn/i18n",
]
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaErrorService } from './src/services/error_service';
/**
* Services that are consumed internally in this component.
* @internal
*/
export interface KibanaErrorBoundaryServices {
onClickRefresh: () => void;
errorService: KibanaErrorService;
}

View file

@ -1356,6 +1356,8 @@
"@kbn/shared-ux-card-no-data-types/*": ["packages/shared-ux/card/no_data/types/*"],
"@kbn/shared-ux-chrome-navigation": ["packages/shared-ux/chrome/navigation"],
"@kbn/shared-ux-chrome-navigation/*": ["packages/shared-ux/chrome/navigation/*"],
"@kbn/shared-ux-error-boundary": ["packages/shared-ux/error_boundary"],
"@kbn/shared-ux-error-boundary/*": ["packages/shared-ux/error_boundary/*"],
"@kbn/shared-ux-file-context": ["packages/shared-ux/file/context"],
"@kbn/shared-ux-file-context/*": ["packages/shared-ux/file/context/*"],
"@kbn/shared-ux-file-image": ["packages/shared-ux/file/image/impl"],

View file

@ -5616,6 +5616,10 @@
version "0.0.0"
uid ""
"@kbn/shared-ux-error-boundary@link:packages/shared-ux/error_boundary":
version "0.0.0"
uid ""
"@kbn/shared-ux-file-context@link:packages/shared-ux/file/context":
version "0.0.0"
uid ""