From 53c83e789b3e47700f6f46b0eb37bdd472fab239 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 23 Oct 2023 07:47:30 -0700 Subject: [PATCH] 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: * image **Unknown/Custom error:** In this case, the error is something outside of known cases where the fix is to refresh: * image ### 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 (c2559e83d27ff5a330b196deb8902c19683811fa) - [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 --- .github/CODEOWNERS | 1 + package.json | 1 + .../flyout_service.test.tsx.snap | 84 +- .../__snapshots__/modal_service.test.tsx.snap | 846 ++++++++++-------- packages/kbn-optimizer/limits.yml | 2 +- packages/kbn-ui-shared-deps-src/BUILD.bazel | 1 + .../kbn-ui-shared-deps-src/src/definitions.js | 1 + packages/kbn-ui-shared-deps-src/src/entry.js | 2 + packages/kbn-ui-shared-deps-src/tsconfig.json | 3 +- .../kibana_context/render/render_provider.tsx | 6 +- .../react/kibana_context/render/tsconfig.json | 1 + packages/shared-ux/error_boundary/BUILD.bazel | 35 + packages/shared-ux/error_boundary/README.mdx | 16 + packages/shared-ux/error_boundary/index.ts | 10 + .../shared-ux/error_boundary/jest.config.js | 13 + .../shared-ux/error_boundary/kibana.jsonc | 5 + .../shared-ux/error_boundary/mocks/index.ts | 12 + .../mocks/src/bad_component.tsx | 31 + .../mocks/src/chunk_load_error_component.tsx | 33 + .../error_boundary/mocks/src/jest.ts | 17 + .../error_boundary/mocks/src/storybook.ts | 43 + .../mocks/src/storybook_template.tsx | 36 + .../shared-ux/error_boundary/package.json | 6 + .../src/services/error_boundary_services.tsx | 54 ++ .../src/services/error_service.test.ts | 52 ++ .../src/services/error_service.ts | 79 ++ .../src/ui/error_boundary.fatal.stories.tsx | 44 + .../ui/error_boundary.recoverable.stories.tsx | 46 + .../src/ui/error_boundary.test.tsx | 75 ++ .../error_boundary/src/ui/error_boundary.tsx | 89 ++ .../src/ui/message_components.tsx | 141 +++ .../error_boundary/src/ui/message_strings.ts | 67 ++ .../shared-ux/error_boundary/tsconfig.json | 25 + packages/shared-ux/error_boundary/types.ts | 18 + tsconfig.base.json | 2 + yarn.lock | 4 + 36 files changed, 1474 insertions(+), 427 deletions(-) create mode 100644 packages/shared-ux/error_boundary/BUILD.bazel create mode 100644 packages/shared-ux/error_boundary/README.mdx create mode 100644 packages/shared-ux/error_boundary/index.ts create mode 100644 packages/shared-ux/error_boundary/jest.config.js create mode 100644 packages/shared-ux/error_boundary/kibana.jsonc create mode 100644 packages/shared-ux/error_boundary/mocks/index.ts create mode 100644 packages/shared-ux/error_boundary/mocks/src/bad_component.tsx create mode 100644 packages/shared-ux/error_boundary/mocks/src/chunk_load_error_component.tsx create mode 100644 packages/shared-ux/error_boundary/mocks/src/jest.ts create mode 100644 packages/shared-ux/error_boundary/mocks/src/storybook.ts create mode 100644 packages/shared-ux/error_boundary/mocks/src/storybook_template.tsx create mode 100644 packages/shared-ux/error_boundary/package.json create mode 100644 packages/shared-ux/error_boundary/src/services/error_boundary_services.tsx create mode 100644 packages/shared-ux/error_boundary/src/services/error_service.test.ts create mode 100644 packages/shared-ux/error_boundary/src/services/error_service.ts create mode 100644 packages/shared-ux/error_boundary/src/ui/error_boundary.fatal.stories.tsx create mode 100644 packages/shared-ux/error_boundary/src/ui/error_boundary.recoverable.stories.tsx create mode 100644 packages/shared-ux/error_boundary/src/ui/error_boundary.test.tsx create mode 100644 packages/shared-ux/error_boundary/src/ui/error_boundary.tsx create mode 100644 packages/shared-ux/error_boundary/src/ui/message_components.tsx create mode 100644 packages/shared-ux/error_boundary/src/ui/message_strings.ts create mode 100644 packages/shared-ux/error_boundary/tsconfig.json create mode 100644 packages/shared-ux/error_boundary/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b07afe6135357..ecfbc723c8f8b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/package.json b/package.json index b52c7716906cd..75ae9e9fd0cdb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap b/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap index 4764d649c146d..41f9899e7b00e 100644 --- a/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -51,7 +51,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -60,24 +80,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, @@ -110,7 +114,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -119,24 +143,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, diff --git a/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap b/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap index a3d283f0cde78..5e66f339c780f 100644 --- a/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap +++ b/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap @@ -18,7 +18,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -27,24 +47,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, @@ -163,7 +167,47 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -172,13 +216,13 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - - - , + + , }, ], }, @@ -335,7 +347,64 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + Some message + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -344,13 +413,13 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + Some message - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - Some message - - , + + , }, ], }, @@ -456,7 +480,64 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + Some message + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -465,13 +546,13 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + Some message - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - Some message - - , + + , }, ], }, @@ -582,7 +618,64 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + Some message + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -591,13 +684,13 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + Some message - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - Some message - - , + + , }, ], }, @@ -703,7 +751,64 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + Some message + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -712,13 +817,13 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + - , - }, - Object {}, - ], - Array [ - Object { - "children": + + , + }, + Object { + "type": "return", + "value": + Some message - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - - - , - }, - Object { - "type": "return", - "value": - - Some message - - , + + , }, ], }, @@ -891,7 +951,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -900,24 +980,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, @@ -950,7 +1014,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -959,24 +1043,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, @@ -1014,7 +1082,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -1023,24 +1111,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, @@ -1073,7 +1145,27 @@ Array [ "calls": Array [ Array [ Object { - "children": + "children": + + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + @@ -1082,24 +1174,8 @@ Array [ mount={[Function]} /> - , - }, - Object {}, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": - - - - , + + , }, ], }, diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index d796ccfc82ab0..02a7b89621f58 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -30,7 +30,7 @@ pageLoadAssetSize: data: 454087 dataViewEditor: 28082 dataViewFieldEditor: 27000 - dataViewManagement: 5000 + dataViewManagement: 5100 dataViews: 48300 dataVisualizer: 27530 devTools: 38637 diff --git a/packages/kbn-ui-shared-deps-src/BUILD.bazel b/packages/kbn-ui-shared-deps-src/BUILD.bazel index 49c2cc62dcfe5..95b9c7ac51e27 100644 --- a/packages/kbn-ui-shared-deps-src/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-src/BUILD.bazel @@ -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, diff --git a/packages/kbn-ui-shared-deps-src/src/definitions.js b/packages/kbn-ui-shared-deps-src/src/definitions.js index ae9dcd3b056f1..e95ff4ac0a732 100644 --- a/packages/kbn-ui-shared-deps-src/src/definitions.js +++ b/packages/kbn-ui-shared-deps-src/src/definitions.js @@ -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', diff --git a/packages/kbn-ui-shared-deps-src/src/entry.js b/packages/kbn-ui-shared-deps-src/src/entry.js index 2491a34193e2e..6e30acf963ab2 100644 --- a/packages/kbn-ui-shared-deps-src/src/entry.js +++ b/packages/kbn-ui-shared-deps-src/src/entry.js @@ -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'); diff --git a/packages/kbn-ui-shared-deps-src/tsconfig.json b/packages/kbn-ui-shared-deps-src/tsconfig.json index 54d86b5eeab76..6b89a4adff0df 100644 --- a/packages/kbn-ui-shared-deps-src/tsconfig.json +++ b/packages/kbn-ui-shared-deps-src/tsconfig.json @@ -26,6 +26,7 @@ "@kbn/rison", "@kbn/std", "@kbn/safer-lodash-set", - "@kbn/repo-info" + "@kbn/repo-info", + "@kbn/shared-ux-error-boundary" ] } diff --git a/packages/react/kibana_context/render/render_provider.tsx b/packages/react/kibana_context/render/render_provider.tsx index 7aee3d73ed054..3deb8d91433cb 100644 --- a/packages/react/kibana_context/render/render_provider.tsx +++ b/packages/react/kibana_context/render/render_provider.tsx @@ -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; @@ -27,7 +27,9 @@ export const KibanaRenderContextProvider: FC = }) => { return ( - {children} + + {children} + ); }; diff --git a/packages/react/kibana_context/render/tsconfig.json b/packages/react/kibana_context/render/tsconfig.json index 61642a72eaec5..479f1a0ea50b1 100644 --- a/packages/react/kibana_context/render/tsconfig.json +++ b/packages/react/kibana_context/render/tsconfig.json @@ -17,5 +17,6 @@ ], "kbn_references": [ "@kbn/react-kibana-context-root", + "@kbn/shared-ux-error-boundary", ] } diff --git a/packages/shared-ux/error_boundary/BUILD.bazel b/packages/shared-ux/error_boundary/BUILD.bazel new file mode 100644 index 0000000000000..a0c94056aeb3a --- /dev/null +++ b/packages/shared-ux/error_boundary/BUILD.bazel @@ -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"], +) 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..77119fce15a90 --- /dev/null +++ b/packages/shared-ux/error_boundary/index.ts @@ -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'; 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/index.ts b/packages/shared-ux/error_boundary/mocks/index.ts new file mode 100644 index 0000000000000..fb05bfe41bc1f --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/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. + */ + +export { BadComponent } from './src/bad_component'; +export { ChunkLoadErrorComponent } from './src/chunk_load_error_component'; +export { getServicesMock } from './src/jest'; +export { KibanaErrorBoundaryStorybookMock } from './src/storybook'; diff --git a/packages/shared-ux/error_boundary/mocks/src/bad_component.tsx b/packages/shared-ux/error_boundary/mocks/src/bad_component.tsx new file mode 100644 index 0000000000000..90d5afd9015e2 --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/bad_component.tsx @@ -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 ( + + Click for error + + ); +}; diff --git a/packages/shared-ux/error_boundary/mocks/src/chunk_load_error_component.tsx b/packages/shared-ux/error_boundary/mocks/src/chunk_load_error_component.tsx new file mode 100644 index 0000000000000..0df91a8fb3ca8 --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/chunk_load_error_component.tsx @@ -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 ( + + Click for error + + ); +}; 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..3a23d58e6083c --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/jest.ts @@ -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(), + }; +}; 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..af044acd0301d --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/storybook.ts @@ -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; + } +} diff --git a/packages/shared-ux/error_boundary/mocks/src/storybook_template.tsx b/packages/shared-ux/error_boundary/mocks/src/storybook_template.tsx new file mode 100644 index 0000000000000..e120cc5d3584f --- /dev/null +++ b/packages/shared-ux/error_boundary/mocks/src/storybook_template.tsx @@ -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 ( + <> + + + + + + + + {children} + + Contact us + + + + ); +}; 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..f72880953eeb5 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/services/error_boundary_services.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, { FC, useContext, useMemo } from 'react'; + +import { KibanaErrorBoundaryServices } from '../../types'; +import { KibanaErrorService } from './error_service'; + +const Context = React.createContext(null); + +/** + * A Context Provider for Jest and Storybooks + */ +export const KibanaErrorBoundaryDepsProvider: FC = ({ + children, + onClickRefresh, + errorService, +}) => { + return {children}; +}; + +/** + * 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 {children}; +}; + +/** + * 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; +} diff --git a/packages/shared-ux/error_boundary/src/services/error_service.test.ts b/packages/shared-ux/error_boundary/src/services/error_service.test.ts new file mode 100644 index 0000000000000..779d79195b60a --- /dev/null +++ b/packages/shared-ux/error_boundary/src/services/error_service.test.ts @@ -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'); + }); +}); 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..99d2b1488cc3c --- /dev/null +++ b/packages/shared-ux/error_boundary/src/services/error_service.ts @@ -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 | 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 | 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 | null + ): ErrorServiceError { + const isFatal = this.getIsFatal(error); + const name = this.getErrorComponentName(errorInfo); + + return { + error, + errorInfo, + isFatal, + name, + }; + } +} 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..20fa5b3818e08 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.fatal.stories.tsx @@ -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 ( + + ); +}; 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..a44c1d5022ca4 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.recoverable.stories.tsx @@ -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 ( + + ); +}; 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 new file mode 100644 index 0000000000000..6cccee1b7881f --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.test.tsx @@ -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('', () => { + let services: KibanaErrorBoundaryServices; + beforeEach(() => { + services = getServicesMock(); + }); + + const Template: FC = ({ children }) => { + return ( + + {children} + + ); + }; + + it('allow children to render when there is no error', () => { + const inputText = 'Hello, beautiful world.'; + const res = render(); + 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( + + ); + (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( + + ); + (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); + }); +}); 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..9a456f597320f --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx @@ -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; + 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) { + 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 ( + + ); + } else { + return ( + + ); + } + } + + // 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 ; +}; 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..568e75f6fa426 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/message_components.tsx @@ -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 | null; + name: string | null; + onClickRefresh: () => void; +} + +const CodePanel: React.FC 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 ( + + + +

{strings.fatal.callout.details.title()}

+
+
+ + +

{errorMessage}

+

{errorTrace}

+
+
+ + + + + {strings.fatal.callout.details.closeButton()} + + + + + {(copy) => ( + + {strings.fatal.callout.details.copyToClipboardButton()} + + )} + + + + +
+ ); +}; + +export const FatalPrompt: React.FC = (props) => { + const { onClickRefresh } = props; + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + + return ( + {strings.fatal.callout.title()}} + color="danger" + iconType="error" + body={ + <> +

{strings.fatal.callout.body()}

+

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

+

+ setIsFlyoutVisible(true)}> + {strings.fatal.callout.showDetailsButton()} + + {isFlyoutVisible ? ( + setIsFlyoutVisible(false)} /> + ) : null} +

+ + } + /> + ); +}; + +export const RecoverablePrompt = (props: ErrorCalloutProps) => { + const { onClickRefresh } = props; + return ( + {strings.recoverable.callout.title()}} + body={

{strings.recoverable.callout.body()}

} + color="warning" + actions={ + + {strings.recoverable.callout.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..08bb325e322f3 --- /dev/null +++ b/packages/shared-ux/error_boundary/src/ui/message_strings.ts @@ -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', + }), + }, + }, +}; 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..8b4d960a30cad --- /dev/null +++ b/packages/shared-ux/error_boundary/types.ts @@ -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; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2da5e2023b35c..56ba0de5dafb6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/yarn.lock b/yarn.lock index 494e5324d4d83..4030af3bd91dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""