diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ccb65e643d219..e65bf0835f758 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -679,6 +679,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 diff --git a/package.json b/package.json index b73a9eb4c779c..715d916a50595 100644 --- a/package.json +++ b/package.json @@ -683,6 +683,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", diff --git a/packages/core/application/core-application-browser-internal/src/ui/app_container.tsx b/packages/core/application/core-application-browser-internal/src/ui/app_container.tsx index 997185e05119c..76c8e26dc0364 100644 --- a/packages/core/application/core-application-browser-internal/src/ui/app_container.tsx +++ b/packages/core/application/core-application-browser-internal/src/ui/app_container.tsx @@ -22,6 +22,8 @@ import { type AppUnmount, type ScopedHistory, } from '@kbn/core-application-browser'; +import { ErrorBoundary, ErrorBoundaryKibanaProvider } from '@kbn/shared-ux-error-boundary'; + import type { Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; @@ -53,6 +55,7 @@ export const AppContainer: FC = ({ }: Props) => { const [showSpinner, setShowSpinner] = useState(true); const [appNotFound, setAppNotFound] = useState(false); + const [appError, setAppError] = useState(null); const elementRef = useRef(null); const unmountRef: MutableRefObject = useRef(null); @@ -87,9 +90,9 @@ export const AppContainer: FC = ({ setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), })) || null; } catch (e) { - // TODO: add error UI // eslint-disable-next-line no-console console.error(e); + setAppError(e); } finally { if (elementRef.current) { setShowSpinner(false); @@ -113,7 +116,11 @@ export const AppContainer: FC = ({ theme$, ]); - return ( + return appError ? ( + + + + ) : ( {appNotFound && } {showSpinner && !appNotFound && ( diff --git a/packages/core/application/core-application-browser-internal/tsconfig.json b/packages/core/application/core-application-browser-internal/tsconfig.json index f00a7fdb928f4..497d069efc596 100644 --- a/packages/core/application/core-application-browser-internal/tsconfig.json +++ b/packages/core/application/core-application-browser-internal/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/core-analytics-browser-mocks", "@kbn/core-analytics-browser", "@kbn/shared-ux-router", + "@kbn/shared-ux-error-boundary", ], "exclude": [ "target/**/*", diff --git a/packages/shared-ux/error_boundary/README.mdx b/packages/shared-ux/error_boundary/README.mdx new file mode 100644 index 0000000000000..005be45c16972 --- /dev/null +++ b/packages/shared-ux/error_boundary/README.mdx @@ -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. diff --git a/packages/shared-ux/error_boundary/index.ts b/packages/shared-ux/error_boundary/index.ts new file mode 100644 index 0000000000000..e288086548b51 --- /dev/null +++ b/packages/shared-ux/error_boundary/index.ts @@ -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. + */ + +export { ErrorBoundary } from './src/ui/error_boundary'; +export { + ErrorBoundaryKibanaProvider, + ErrorBoundaryProvider, +} from './src/services/error_boundary_services'; diff --git a/packages/shared-ux/error_boundary/jest.config.js b/packages/shared-ux/error_boundary/jest.config.js new file mode 100644 index 0000000000000..7bb04347d113c --- /dev/null +++ b/packages/shared-ux/error_boundary/jest.config.js @@ -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: ['/packages/shared-ux/error_boundary'], +}; diff --git a/packages/shared-ux/error_boundary/kibana.jsonc b/packages/shared-ux/error_boundary/kibana.jsonc new file mode 100644 index 0000000000000..c7e6f9b517962 --- /dev/null +++ b/packages/shared-ux/error_boundary/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-error-boundary", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/shared-ux/error_boundary/mocks/src/jest.ts b/packages/shared-ux/error_boundary/mocks/src/jest.ts new file mode 100644 index 0000000000000..653075e7da104 --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/jest.ts @@ -0,0 +1,20 @@ +/* + * 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 { ErrorService } from '../../src/services/error_service'; +import { ToastsService } from '../../src/services/toasts_service'; +import { ErrorBoundaryServices } from '../../types'; + +export const getServicesMock = (): ErrorBoundaryServices => { + const reloadWindow = jest.fn().mockResolvedValue(undefined); + return { + reloadWindow, + errorService: new ErrorService(), + toastsService: new ToastsService({ reloadWindow: jest.fn() }), + }; +}; diff --git a/packages/shared-ux/error_boundary/mocks/src/storybook.ts b/packages/shared-ux/error_boundary/mocks/src/storybook.ts new file mode 100644 index 0000000000000..ee191b89ab4e4 --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/storybook.ts @@ -0,0 +1,74 @@ +/* + * 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 { ErrorService } from '../../src/services/error_service'; +import { ToastsService } from '../../src/services/toasts_service'; +import { ErrorBoundaryServices } from '../../types'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Params {} + +export class ErrorBoundaryStorybookMock extends AbstractStorybookMock<{}, ErrorBoundaryServices> { + propArguments = {}; + + serviceArguments = {}; + + dependencies = []; + + getServices(params: Params = {}): ErrorBoundaryServices { + const reloadWindowAction = action('Reload window'); + const reloadWindow = () => { + reloadWindowAction(); + }; + + return { + ...params, + reloadWindow, + errorService: new ErrorService(), + toastsService: new ToastsService({ reloadWindow }), + }; + } + + getProps(params: Params) { + return params; + } +} + +interface UserTableEntry { + id: string; + firstName: string | null | undefined; + lastName: string; + action: string; +} + +export const getMockUserTable = (): UserTableEntry[] => { + const users: UserTableEntry[] = []; + + users.push({ + id: 'user-123', + firstName: 'Rodger', + lastName: 'Turcotte', + action: 'Rodger.Turcotte', + }); + users.push({ + id: 'user-345', + firstName: 'Bella', + lastName: 'Cremin', + action: 'Bella23', + }); + users.push({ + id: 'user-678', + firstName: 'Layne', + lastName: 'Franecki', + action: 'The_Real_Layne_2', + }); + + return users; +}; diff --git a/packages/shared-ux/error_boundary/package.json b/packages/shared-ux/error_boundary/package.json new file mode 100644 index 0000000000000..5fdc7a08be203 --- /dev/null +++ b/packages/shared-ux/error_boundary/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/shared-ux/error_boundary/src/services/error_boundary_services.tsx b/packages/shared-ux/error_boundary/src/services/error_boundary_services.tsx new file mode 100644 index 0000000000000..8bb2da0b25a4a --- /dev/null +++ b/packages/shared-ux/error_boundary/src/services/error_boundary_services.tsx @@ -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 React, { FC, useContext } from 'react'; + +import { ErrorBoundaryServices } from '../../types'; +import { ErrorService } from './error_service'; +import { StatefulToastList, ToastsService } from './toasts_service'; + +const Context = React.createContext(null); + +/** + * A Context Provider for Jest and Storybooks + */ +export const ErrorBoundaryProvider: FC = ({ + children, + reloadWindow, + errorService, + toastsService, +}) => { + return ( + + {children} + + + ); +}; + +/** + * Kibana-specific Provider that maps dependencies to services. + */ +export const ErrorBoundaryKibanaProvider: FC = ({ children }) => { + const reloadWindow = () => window.location.reload(); + const toastsService = new ToastsService({ reloadWindow }); + + const value: ErrorBoundaryServices = { + reloadWindow, + errorService: new ErrorService(), + toastsService, + }; + + return ( + + {children} + + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useErrorBoundary(): ErrorBoundaryServices { + 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; +} diff --git a/packages/shared-ux/error_boundary/src/services/error_service.ts b/packages/shared-ux/error_boundary/src/services/error_service.ts new file mode 100644 index 0000000000000..e9d9b3fdfe8ec --- /dev/null +++ b/packages/shared-ux/error_boundary/src/services/error_service.ts @@ -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 React from 'react'; + +const MATCH_CHUNK_LOADERROR = /ChunkLoadError/; + +/** + * Kibana Error Boundary Services: Error Service + * Each Error Boundary tracks an instance of this class + * @internal + */ +export class ErrorService { + /** + * 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 | 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; + } + + public registerError( + error: Error, + errorInfo: Partial | null + ): ErrorServiceError { + const isFatal = this.getIsFatal(error); + const name = this.getErrorComponentName(errorInfo); + + return { + error, + errorInfo, + isFatal, + name, + }; + } +} + +interface ErrorServiceError { + error: Error; + errorInfo?: Partial | null; + name: string | null; + isFatal: boolean; +} diff --git a/packages/shared-ux/error_boundary/src/services/toasts_service.tsx b/packages/shared-ux/error_boundary/src/services/toasts_service.tsx new file mode 100644 index 0000000000000..c4ad34d167eb8 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/services/toasts_service.tsx @@ -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 from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import * as Rx from 'rxjs'; + +import { EuiGlobalToastList, EuiGlobalToastListProps } from '@elastic/eui'; + +import { ErrorBoundaryUIServices } from '../../types'; +import { FatalToastText, RecoverableToastText } from '../ui/message_components'; +import { errorMessageStrings as strings } from '../ui/message_strings'; + +export class ToastsService { + private _toasts = new Rx.BehaviorSubject([]); + + constructor(private services: ErrorBoundaryUIServices) {} + + public get toasts$() { + return this._toasts.asObservable(); + } + + public addError(_error: Error, isFatal: boolean) { + if (isFatal) { + this._toasts.next([ + { + id: 'fatal-123', // FIXME + title: strings.fatal.toast.title(), + text: , + }, + ]); + } else { + this._toasts.next([ + { + id: 'recoverable-123', // FIXME + title: strings.recoverable.toast.title(), + text: , + }, + ]); + } + } +} + +export type Toasts = EuiGlobalToastListProps['toasts']; + +export const StatefulToastList = ({ toasts$ }: { toasts$: Rx.Observable }) => { + const toasts = useObservable(toasts$); + return {}} toastLifeTimeMs={9000} />; +}; diff --git a/packages/shared-ux/error_boundary/src/ui/error_boundary.fatal.stories.tsx b/packages/shared-ux/error_boundary/src/ui/error_boundary.fatal.stories.tsx new file mode 100644 index 0000000000000..53589f5a41bc3 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.fatal.stories.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 { action } from '@storybook/addon-actions'; +import { Meta, Story } from '@storybook/react'; +import React, { FC, useState } from 'react'; + +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiLink, + EuiPageTemplate, +} from '@elastic/eui'; + +import mdx from '../../README.mdx'; +import { ErrorBoundaryStorybookMock, getMockUserTable } from '../../mocks/src/storybook'; +import { ErrorBoundaryProvider } from '../services/error_boundary_services'; +import { ErrorBoundary } from './error_boundary'; + +const storybookMock = new ErrorBoundaryStorybookMock(); + +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; + +const Template: FC = ({ children }) => { + return ( + + + {children} + + Contact us + + + ); +}; + +const BadComponent = () => { + const [hasError, setHasError] = useState(false); + + if (hasError) { + throw new Error('This is an error to show in a toast!'); // custom error + } + + const clickedForError = action('clicked for error'); + const handleClick = () => { + clickedForError(); + setHasError(true); + }; + + return Click for error; +}; + +export const ErrorInCallout: Story = () => { + const services = storybookMock.getServices(); + + return ( + + ); +}; + +export const ErrorInToast: Story = () => { + const services = storybookMock.getServices(); + const users = getMockUserTable(); + + const columns: Array> = [ + { field: 'firstName', name: 'First Name' }, + { field: 'lastName', name: 'Last Name' }, + { + field: 'action', + name: 'Action', + render: () => ( + + + + ), + }, + ]; + + return ( + + ); +}; diff --git a/packages/shared-ux/error_boundary/src/ui/error_boundary.recoverable.stories.tsx b/packages/shared-ux/error_boundary/src/ui/error_boundary.recoverable.stories.tsx new file mode 100644 index 0000000000000..52b8290767ccc --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.recoverable.stories.tsx @@ -0,0 +1,116 @@ +/* + * 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 { action } from '@storybook/addon-actions'; +import { Meta, Story } from '@storybook/react'; +import React, { FC, useState } from 'react'; + +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiLink, + EuiPageTemplate, +} from '@elastic/eui'; + +import mdx from '../../README.mdx'; +import { ErrorBoundaryStorybookMock, getMockUserTable } from '../../mocks/src/storybook'; +import { ErrorBoundaryProvider } from '../services/error_boundary_services'; +import { ErrorBoundary } from './error_boundary'; + +const storybookMock = new ErrorBoundaryStorybookMock(); + +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; + +const Template: FC = ({ children }) => { + return ( + + + {children} + + Contact us + + + ); +}; + +const BadComponent = () => { + 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 ( + + Click for error + + ); +}; + +export const ErrorInCallout: Story = () => { + const services = storybookMock.getServices(); + + return ( + + ); +}; + +export const ErrorInToast: Story = () => { + const services = storybookMock.getServices(); + const users = getMockUserTable(); + const columns: Array> = [ + { field: 'firstName', name: 'First Name' }, + { field: 'lastName', name: 'Last Name' }, + { + field: 'action', + name: 'Action', + render: () => ( + + + + ), + }, + ]; + + return ( + + ); +}; diff --git a/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx b/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx new file mode 100644 index 0000000000000..20b7cc82a8de6 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx @@ -0,0 +1,132 @@ +/* + * 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 useObservable from 'react-use/lib/useObservable'; + +import { ErrorBoundaryServices, Toasts } from '../../types'; +import { useErrorBoundary } from '../services/error_boundary_services'; +import { + FatalInline, + FatalPrompt, + RecoverableInline, + RecoverablePrompt, +} from './message_components'; + +interface ErrorBoundaryState { + error: null | Error; + errorInfo: null | Partial; + messageAs: 'callout' | 'toast'; + componentName: null | string; + isFatal: null | boolean; +} + +interface ErrorBoundaryProps { + /** + * Consumers may control how error message is presented: either as a toast message or a callout. Default is a + * toast message. + */ + as?: 'callout' | 'toast'; + /** + * List of toasts to pass to the EuiGlobalToastList + */ + toasts?: Toasts; + /** + * If an error has already been caught, we can provide it here to present the error UI + */ + error?: Error; + /** + * + */ + children?: React.ReactNode; +} + +class ErrorBoundaryInternal extends React.Component< + ErrorBoundaryProps & ErrorBoundaryServices, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps & ErrorBoundaryServices) { + super(props); + this.state = { + error: props.error ?? null, + errorInfo: null, + messageAs: props.as === 'callout' ? props.as : 'toast', + componentName: null, + isFatal: null, + }; + } + + componentDidCatch(error: Error, errorInfo: Partial) { + const { name, isFatal } = this.props.errorService.registerError(error, errorInfo); + this.setState(() => { + if (this.state.messageAs === 'toast') { + this.props.toastsService.addError(error, isFatal); + } + return { error, errorInfo, componentName: name, isFatal }; + }); + } + + render() { + if (this.state.error != null) { + const { error, errorInfo, componentName, isFatal } = this.state; + + if (isFatal) { + switch (this.state.messageAs) { + case 'toast': + return ( + + ); + default: + return ( + + ); + } + } else { + switch (this.state.messageAs) { + case 'toast': + return ( + + ); + default: + return ( + + ); + } + } + } + + // not in error state + return this.props.children; + } +} + +export const ErrorBoundary = (props: ErrorBoundaryProps) => { + const services = useErrorBoundary(); + const toasts = useObservable(services.toastsService.toasts$); + return ; +}; diff --git a/packages/shared-ux/error_boundary/src/ui/message_components.tsx b/packages/shared-ux/error_boundary/src/ui/message_components.tsx new file mode 100644 index 0000000000000..516142c0ea7a6 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/message_components.tsx @@ -0,0 +1,156 @@ +/* + * 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 { + EuiAccordion, + EuiButton, + EuiCallOut, + EuiCodeBlock, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiSpacer, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import { errorMessageStrings as strings } from './message_strings'; + +const DATA_TEST_SUBJ_PAGE_REFRESH_BUTTON = 'pageReloadButton'; +const DATA_TEST_SUBJ_PAGE_DETAILS_BUTTON = 'showDetailsButton'; + +export interface ErrorCalloutProps { + error: Error; + errorInfo: Partial | null; + name: string | null; + reloadWindow: () => void; +} + +export const FatalInline = (_props: ErrorCalloutProps) => { + return ; +}; + +export const FatalPrompt = (props: ErrorCalloutProps) => { + const { error, errorInfo, name: errorComponentName, reloadWindow } = props; + const errorBoundaryAccordionId = useGeneratedHtmlId({ prefix: 'errorBoundaryAccordion' }); + return ( + +

{strings.fatal.callout.body}

+ + + + {errorComponentName && ( +

{strings.fatal.callout.details.componentName(errorComponentName)}

+ )} + {error?.message &&

{error.message}

} + {errorInfo?.componentStack} +
+
+
+ +

+ + {strings.fatal.callout.pageReloadButton()} + +

+
+ ); +}; + +export const FatalToastText = ({ reloadWindow }: { reloadWindow: () => void }) => { + return ( + + +

Try refreshing the page.

+
+ + + + {}} + data-test-subj={DATA_TEST_SUBJ_PAGE_DETAILS_BUTTON} + fill={false} + color="danger" + > + {strings.fatal.toast.showDetailsButton()} + + + + + {strings.fatal.toast.pageReloadButton()} + + + + +
+ ); +}; + +export const RecoverablePrompt = (props: ErrorCalloutProps) => { + const { reloadWindow } = props; + return ( + {strings.recoverable.callout.title()}} + body={

{strings.recoverable.callout.body()}

} + color="primary" + actions={ + + {strings.recoverable.callout.pageReloadButton()} + + } + /> + ); +}; + +export const RecoverableInline = (props: ErrorCalloutProps) => { + return ( + + + {strings.recoverable.inline.linkText()} + + + ); +}; + +export const RecoverableToastText = ({ reloadWindow }: { reloadWindow: () => void }) => { + return ( + + +

{strings.recoverable.toast.body()}

+
+ + + + + {strings.recoverable.toast.pageReloadButton()} + + + + +
+ ); +}; diff --git a/packages/shared-ux/error_boundary/src/ui/message_strings.ts b/packages/shared-ux/error_boundary/src/ui/message_strings.ts new file mode 100644 index 0000000000000..ef1de490a6e59 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/message_strings.ts @@ -0,0 +1,97 @@ +/* + * 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: { + inline: { + title: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.inline.text', { + defaultMessage: 'Error: unable to load.', + }), + }, + callout: { + title: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.title', { + defaultMessage: 'A fatal error was encountered', + }), + body: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.body', { + defaultMessage: 'Try refreshing this page.', + }), + showDetailsButton: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.detailButton', { + defaultMessage: 'Show detail', + }), + details: { + componentName: (errorComponentName: string) => + i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.details', { + defaultMessage: 'An error occurred in {name}:', + values: { name: errorComponentName }, + }), + }, + pageReloadButton: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.prompt.pageReloadButton', { + defaultMessage: 'Refresh', + }), + }, + toast: { + title: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.toast.title', { + defaultMessage: 'A fatal error was encountered.', + }), + showDetailsButton: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.toast.details', { + defaultMessage: 'Show details', + }), + pageReloadButton: () => + i18n.translate('sharedUXPackages.error_boundary.fatal.toast.pageReloadButton', { + defaultMessage: 'Refresh', + }), + }, + }, + recoverable: { + callout: { + title: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.title', { + defaultMessage: 'Sorry, please refresh', + }), + body: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.body', { + defaultMessage: + 'An error occurred when trying to load a part of the page. Please try refreshing.', + }), + pageReloadButton: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.prompt.pageReloadButton', { + defaultMessage: 'Refresh', + }), + }, + inline: { + linkText: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.inline.pageReloadButton', { + defaultMessage: 'Please refresh the page', + }), + }, + toast: { + title: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.toast.title', { + defaultMessage: 'Sorry, please refresh', + }), + body: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.toast.body', { + defaultMessage: + 'An error occurred when trying to load a part of the page. Please try refreshing.', + }), + pageReloadButton: () => + i18n.translate('sharedUXPackages.error_boundary.recoverable.toast.pageReloadButton', { + defaultMessage: 'Refresh', + }), + }, + }, +}; diff --git a/packages/shared-ux/error_boundary/tsconfig.json b/packages/shared-ux/error_boundary/tsconfig.json new file mode 100644 index 0000000000000..8a3cb2b5301d6 --- /dev/null +++ b/packages/shared-ux/error_boundary/tsconfig.json @@ -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", + ] +} diff --git a/packages/shared-ux/error_boundary/types.ts b/packages/shared-ux/error_boundary/types.ts new file mode 100644 index 0000000000000..19926ca04bd2a --- /dev/null +++ b/packages/shared-ux/error_boundary/types.ts @@ -0,0 +1,34 @@ +/* + * 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 { ErrorService } from './src/services/error_service'; +import { ToastsService, Toasts } from './src/services/toasts_service'; + +export interface ErrorBoundaryUIServices { + reloadWindow: () => void; +} + +/** + * Services that are consumed internally in this component. + * @internal + */ +export interface ErrorBoundaryServices extends ErrorBoundaryUIServices { + errorService: ErrorService; + toastsService: ToastsService; +} + +/** + * Kibana dependencies required to render this component. + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ErrorBoundaryKibanaDependencies { + // TODO analytics +} + +export type { Toasts }; diff --git a/src/plugins/share/public/services/share_menu_manager.tsx b/src/plugins/share/public/services/share_menu_manager.tsx index 658d01063b1ea..e8a31db03bebf 100644 --- a/src/plugins/share/public/services/share_menu_manager.tsx +++ b/src/plugins/share/public/services/share_menu_manager.tsx @@ -13,6 +13,7 @@ import { EuiWrappingPopover } from '@elastic/eui'; import { CoreStart, ThemeServiceStart } from '@kbn/core/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { ErrorBoundary, ErrorBoundaryKibanaProvider } from '@kbn/shared-ux-error-boundary'; import { ShareContextMenu } from '../components/share_context_menu'; import { ShareMenuItem, ShowShareMenuOptions } from '../types'; import { ShareMenuRegistryStart } from './share_menu_registry'; @@ -108,25 +109,29 @@ export class ShareMenuManager { panelPaddingSize="none" anchorPosition="downLeft" > - + + + + + diff --git a/src/plugins/share/tsconfig.json b/src/plugins/share/tsconfig.json index 47fa82eae4c97..2c9b9660fa0f6 100644 --- a/src/plugins/share/tsconfig.json +++ b/src/plugins/share/tsconfig.json @@ -15,6 +15,7 @@ "@kbn/core-custom-branding-browser", "@kbn/core-saved-objects-utils-server", "@kbn/react-kibana-context-theme", + "@kbn/shared-ux-error-boundary", ], "exclude": [ "target/**/*", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3d3f2fd964a06..5ce0a5d50d2b3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1352,6 +1352,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"], diff --git a/yarn.lock b/yarn.lock index 9fb4f5f8d852b..dc4e0b6a74266 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5644,6 +5644,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 ""