diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 94f4285976c2a..72f3f7e7a884f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -353,6 +353,7 @@ src/plugins/embeddable @elastic/kibana-presentation x-pack/examples/embedded_lens_example @elastic/kibana-visualizations x-pack/plugins/encrypted_saved_objects @elastic/kibana-security x-pack/plugins/enterprise_search @elastic/enterprise-search-frontend +examples/error_boundary @elastic/appex-sharedux packages/kbn-es @elastic/kibana-operations packages/kbn-es-archiver @elastic/kibana-operations @elastic/appex-qa packages/kbn-es-errors @elastic/kibana-core diff --git a/examples/error_boundary/README.md b/examples/error_boundary/README.md new file mode 100755 index 0000000000000..3a293c9a7b465 --- /dev/null +++ b/examples/error_boundary/README.md @@ -0,0 +1,3 @@ +## Error Boundary Example + +A very simple example plugin for testing Kibana Error Boundary. diff --git a/examples/error_boundary/kibana.jsonc b/examples/error_boundary/kibana.jsonc new file mode 100644 index 0000000000000..3acabfbb5006c --- /dev/null +++ b/examples/error_boundary/kibana.jsonc @@ -0,0 +1,14 @@ +{ + "type": "plugin", + "id": "@kbn/error-boundary-example-plugin", + "owner": "@elastic/appex-sharedux", + "description": "A plugin which exemplifes the KibanaErrorBoundary", + "plugin": { + "id": "error_boundary_example", + "server": false, + "browser": true, + "requiredPlugins": [ + "developerExamples" + ] + } +} diff --git a/examples/error_boundary/public/index.ts b/examples/error_boundary/public/index.ts new file mode 100755 index 0000000000000..41d35a8f5bec1 --- /dev/null +++ b/examples/error_boundary/public/index.ts @@ -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. + */ +import { ErrorBoundaryExamplePlugin } from './plugin'; + +export function plugin() { + return new ErrorBoundaryExamplePlugin(); +} diff --git a/examples/error_boundary/public/plugin.tsx b/examples/error_boundary/public/plugin.tsx new file mode 100755 index 0000000000000..2c5d4e7487005 --- /dev/null +++ b/examples/error_boundary/public/plugin.tsx @@ -0,0 +1,111 @@ +/* + * 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 React, { useState } from 'react'; +import ReactDOM from 'react-dom'; + +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; +import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; + +interface SetupDeps { + developerExamples: DeveloperExamplesSetup; +} + +const useErrors = () => { + return useState(false); +}; + +export const FatalComponent = () => { + const [hasError, setHasError] = useErrors(); + + if (hasError) { + const fatalError = new Error('Example of unknown error type'); + throw fatalError; + } + + return ( + { + setHasError(true); + }} + data-test-subj="fatalErrorBtn" + > + Click for fatal error + + ); +}; + +export const RecoverableComponent = () => { + const [hasError, setHasError] = useErrors(); + + if (hasError) { + // FIXME: use network interception to disable responses + // for chunk requests and attempt to lazy-load a component + // https://github.com/elastic/kibana/issues/170777 + const upgradeError = new Error('ChunkLoadError'); + upgradeError.name = 'ChunkLoadError'; + throw upgradeError; + } + + return ( + { + setHasError(true); + }} + data-test-subj="recoverableErrorBtn" + > + Click for recoverable error + + ); +}; + +export class ErrorBoundaryExamplePlugin implements Plugin { + public setup(core: CoreSetup, deps: SetupDeps) { + // Register an application into the side navigation menu + core.application.register({ + id: 'errorBoundaryExample', + title: 'Error Boundary Example', + async mount({ element }: AppMountParameters) { + ReactDOM.render( + + + + + + + + + + + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); + }, + }); + + // This section is only needed to get this example plugin to show up in our Developer Examples. + deps.developerExamples.register({ + appId: 'errorBoundaryExample', + title: 'Error Boundary Example Application', + description: `Build a plugin that registers an application that simply says "Error Boundary Example"`, + }); + } + public start(_core: CoreStart) { + return {}; + } + public stop() {} +} diff --git a/examples/error_boundary/tsconfig.json b/examples/error_boundary/tsconfig.json new file mode 100644 index 0000000000000..2df5023cbdadb --- /dev/null +++ b/examples/error_boundary/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "index.ts", + "common/**/*.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../typings/**/*" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/developer-examples-plugin", + "@kbn/shared-ux-error-boundary", + "@kbn/shared-ux-page-kibana-template" + ] +} diff --git a/package.json b/package.json index 869529d6836a9..4e4c2441fe9fc 100644 --- a/package.json +++ b/package.json @@ -398,6 +398,7 @@ "@kbn/embedded-lens-example-plugin": "link:x-pack/examples/embedded_lens_example", "@kbn/encrypted-saved-objects-plugin": "link:x-pack/plugins/encrypted_saved_objects", "@kbn/enterprise-search-plugin": "link:x-pack/plugins/enterprise_search", + "@kbn/error-boundary-example-plugin": "link:examples/error_boundary", "@kbn/es-errors": "link:packages/kbn-es-errors", "@kbn/es-query": "link:packages/kbn-es-query", "@kbn/es-types": "link:packages/kbn-es-types", diff --git a/packages/shared-ux/error_boundary/src/ui/error_boundary.test.tsx b/packages/shared-ux/error_boundary/src/ui/error_boundary.test.tsx index ed5d4a60534c0..05a69a7ccab9e 100644 --- a/packages/shared-ux/error_boundary/src/ui/error_boundary.test.tsx +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.test.tsx @@ -49,7 +49,7 @@ describe('', () => { expect(await findByText(strings.recoverable.callout.title())).toBeVisible(); expect(await findByText(strings.recoverable.callout.pageReloadButton())).toBeVisible(); - (await findByTestId('recoverablePromptReloadBtn')).click(); + (await findByTestId('errorBoundaryRecoverablePromptReloadBtn')).click(); expect(reloadSpy).toHaveBeenCalledTimes(1); }); @@ -69,7 +69,7 @@ describe('', () => { expect(await findByText(strings.fatal.callout.showDetailsButton())).toBeVisible(); expect(await findByText(strings.fatal.callout.pageReloadButton())).toBeVisible(); - (await findByTestId('fatalPromptReloadBtn')).click(); + (await findByTestId('errorBoundaryFatalPromptReloadBtn')).click(); expect(reloadSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/shared-ux/error_boundary/src/ui/message_components.tsx b/packages/shared-ux/error_boundary/src/ui/message_components.tsx index 6e481cb5d8216..1e44fa68141bc 100644 --- a/packages/shared-ux/error_boundary/src/ui/message_components.tsx +++ b/packages/shared-ux/error_boundary/src/ui/message_components.tsx @@ -55,7 +55,7 @@ const CodePanel: React.FC void }> = (props) - +

{(error.stack ?? error.toString()) + '\n\n'}

{errorName} @@ -93,25 +93,29 @@ export const FatalPrompt: React.FC = (props) => { return ( {strings.fatal.callout.title()}} + title={

{strings.fatal.callout.title()}

} color="danger" iconType="error" body={ <> -

{strings.fatal.callout.body()}

+

{strings.fatal.callout.body()}

{strings.fatal.callout.pageReloadButton()}

- setIsFlyoutVisible(true)}> + setIsFlyoutVisible(true)} + data-test-subj="errorBoundaryFatalShowDetailsBtn" + > {strings.fatal.callout.showDetailsButton()} {isFlyoutVisible ? ( @@ -128,17 +132,25 @@ export const RecoverablePrompt = (props: ErrorCalloutProps) => { const { onClickRefresh } = props; return ( {strings.recoverable.callout.title()}} - body={

{strings.recoverable.callout.body()}

} + title={ +

+ {strings.recoverable.callout.title()} +

+ } color="warning" + iconType="warning" + body={ +

+ {strings.recoverable.callout.body()} +

+ } actions={ {strings.recoverable.callout.pageReloadButton()} diff --git a/test/examples/config.js b/test/examples/config.js index 36f4a007b7983..dbc9d32055cc7 100644 --- a/test/examples/config.js +++ b/test/examples/config.js @@ -31,6 +31,7 @@ export default async function ({ readConfigFile }) { require.resolve('./content_management'), require.resolve('./unified_field_list_examples'), require.resolve('./discover_customization_examples'), + require.resolve('./error_boundary'), ], services: { ...functionalConfig.get('services'), diff --git a/test/examples/error_boundary/index.ts b/test/examples/error_boundary/index.ts new file mode 100644 index 0000000000000..f240ebd1442b2 --- /dev/null +++ b/test/examples/error_boundary/index.ts @@ -0,0 +1,68 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const log = getService('log'); + + describe('Error Boundary Examples', () => { + before(async () => { + await PageObjects.common.navigateToApp('errorBoundaryExample'); + await testSubjects.existOrFail('errorBoundaryExampleHeader'); + }); + + it('fatal error', async () => { + log.debug('clicking button for fatal error'); + await testSubjects.click('fatalErrorBtn'); + const errorHeader = await testSubjects.getVisibleText('errorBoundaryFatalHeader'); + expect(errorHeader).to.not.be(undefined); + + log.debug('checking that the error has taken over the page'); + await testSubjects.missingOrFail('errorBoundaryExampleHeader'); + + await testSubjects.click('errorBoundaryFatalShowDetailsBtn'); + const errorString = await testSubjects.getVisibleText('errorBoundaryFatalDetailsErrorString'); + expect(errorString).to.match(/Error: Example of unknown error type/); + + log.debug('closing error flyout'); + await testSubjects.click('euiFlyoutCloseButton'); + + log.debug('clicking page refresh'); + await testSubjects.click('errorBoundaryFatalPromptReloadBtn'); + + await retry.try(async () => { + log.debug('checking for page refresh'); + await testSubjects.existOrFail('errorBoundaryExampleHeader'); + }); + }); + + it('recoverable error', async () => { + log.debug('clicking button for recoverable error'); + await testSubjects.click('recoverableErrorBtn'); + const errorHeader = await testSubjects.getVisibleText('errorBoundaryRecoverableHeader'); + expect(errorHeader).to.not.be(undefined); + + log.debug('checking that the error has taken over the page'); + await testSubjects.missingOrFail('errorBoundaryExampleHeader'); + + log.debug('clicking page refresh'); + await testSubjects.click('errorBoundaryRecoverablePromptReloadBtn'); + + await retry.try(async () => { + log.debug('checking for page refresh'); + await testSubjects.existOrFail('errorBoundaryExampleHeader'); + }); + }); + }); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 107b8e1f6ff46..40b1ede664328 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -700,6 +700,8 @@ "@kbn/encrypted-saved-objects-plugin/*": ["x-pack/plugins/encrypted_saved_objects/*"], "@kbn/enterprise-search-plugin": ["x-pack/plugins/enterprise_search"], "@kbn/enterprise-search-plugin/*": ["x-pack/plugins/enterprise_search/*"], + "@kbn/error-boundary-example-plugin": ["examples/error_boundary"], + "@kbn/error-boundary-example-plugin/*": ["examples/error_boundary/*"], "@kbn/es": ["packages/kbn-es"], "@kbn/es/*": ["packages/kbn-es/*"], "@kbn/es-archiver": ["packages/kbn-es-archiver"], diff --git a/yarn.lock b/yarn.lock index 813e52bcf4912..68faac71450cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4304,6 +4304,10 @@ version "0.0.0" uid "" +"@kbn/error-boundary-example-plugin@link:examples/error_boundary": + version "0.0.0" + uid "" + "@kbn/es-archiver@link:packages/kbn-es-archiver": version "0.0.0" uid ""