From b352f67bdb84127954fc90a1390e8a994437e231 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Tue, 3 Sep 2019 13:03:05 -0500 Subject: [PATCH] Add ApplicationService Mounting (#41007) * Add core-only bundle * Add ApplicationService mounting * Add LegacyCore{Setup,Start} * Fix PR comments * Add functional tests * Fix PR comments * Fix PR comments * Remove other usages of rootRoute * Use state field notation * Add support for open in new tab * Fix PR comments * Fix pesky await from the dead * Update docs * Bump @types/history --- .../core/public/kibana-plugin-public.app.md | 20 ++ .../public/kibana-plugin-public.app.mount.md | 13 + ...bana-plugin-public.appbase.capabilities.md | 13 + ...ibana-plugin-public.appbase.euiicontype.md | 13 + .../kibana-plugin-public.appbase.icon.md | 13 + .../public/kibana-plugin-public.appbase.id.md | 11 + .../public/kibana-plugin-public.appbase.md | 25 ++ .../kibana-plugin-public.appbase.order.md | 13 + .../kibana-plugin-public.appbase.title.md | 13 + .../kibana-plugin-public.appbase.tooltip$.md | 13 + .../kibana-plugin-public.applicationsetup.md | 3 +- ...lugin-public.applicationsetup.register.md} | 10 +- ...c.applicationsetup.registermountcontext.md | 25 ++ ...n-public.applicationstart.availableapps.md | 13 - ...in-public.applicationstart.geturlforapp.md | 27 ++ .../kibana-plugin-public.applicationstart.md | 9 +- ...n-public.applicationstart.navigatetoapp.md | 28 ++ ...c.applicationstart.registermountcontext.md | 25 ++ ...bana-plugin-public.appmountcontext.core.md | 22 ++ .../kibana-plugin-public.appmountcontext.md | 20 ++ ...n-public.appmountparameters.appbasepath.md | 53 ++++ ...lugin-public.appmountparameters.element.md | 13 + ...kibana-plugin-public.appmountparameters.md | 20 ++ .../public/kibana-plugin-public.appunmount.md | 13 + ...ibana-plugin-public.chromenavlink.order.md | 2 +- ...ana-plugin-public.coresetup.application.md | 13 + .../public/kibana-plugin-public.coresetup.md | 1 + ...ana-plugin-public.corestart.application.md | 2 +- .../public/kibana-plugin-public.corestart.md | 2 +- ...public.legacycoresetup.injectedmetadata.md | 15 + .../kibana-plugin-public.legacycoresetup.md | 28 ++ ...public.legacycorestart.injectedmetadata.md | 15 + .../kibana-plugin-public.legacycorestart.md | 28 ++ .../core/public/kibana-plugin-public.md | 7 + package.json | 2 + renovate.json5 | 16 +- .../text/0004_application_service_mounting.md | 67 ++-- .../application/application_service.mock.ts | 43 ++- .../application_service.test.mocks.ts | 8 + .../application/application_service.test.tsx | 212 +++++++++++-- .../application/application_service.tsx | 234 ++++++++------ .../capabilities/capabilities_service.mock.ts | 6 +- .../capabilities/capabilities_service.test.ts | 20 +- .../capabilities/capabilities_service.tsx | 27 +- src/core/public/application/index.ts | 14 +- .../integration_tests/router.test.tsx | 130 ++++++++ src/core/public/application/types.ts | 300 ++++++++++++++++++ .../public/application/ui/app_container.tsx | 111 +++++++ .../application/ui/app_not_found_screen.tsx | 51 +++ src/core/public/application/ui/app_router.tsx | 53 ++++ src/core/public/application/ui/index.ts | 21 ++ src/core/public/chrome/chrome_service.mock.ts | 2 +- src/core/public/chrome/chrome_service.test.ts | 4 +- src/core/public/chrome/chrome_service.tsx | 17 +- src/core/public/chrome/nav_links/nav_link.ts | 10 +- .../nav_links/nav_links_service.test.ts | 31 +- .../chrome/nav_links/nav_links_service.ts | 24 +- src/core/public/chrome/ui/header/header.tsx | 54 +++- src/core/public/core_system.test.ts | 4 +- src/core/public/core_system.ts | 57 +++- src/core/public/http/http_service.mock.ts | 19 +- src/core/public/index.ts | 37 ++- .../injected_metadata_service.mock.ts | 2 + .../injected_metadata_service.ts | 7 + src/core/public/legacy/legacy_service.test.ts | 27 +- src/core/public/legacy/legacy_service.ts | 67 +++- src/core/public/mocks.ts | 1 + src/core/public/plugins/plugin_context.ts | 11 +- .../public/plugins/plugins_service.test.ts | 11 +- src/core/public/plugins/plugins_service.ts | 2 +- src/core/public/public.api.md | 81 +++-- .../rendering/rendering_service.test.tsx | 81 +++-- .../public/rendering/rendering_service.tsx | 28 +- src/core/utils/context.mock.ts | 4 +- src/core/utils/pick.ts | 5 +- src/dev/jest/config.integration.js | 1 + .../tests_bundle/tests_entry_template.js | 1 + src/legacy/ui/public/chrome/chrome.js | 1 - .../ui/public/chrome/directives/kbn_chrome.js | 24 +- .../public/legacy_compat/angular_config.tsx | 20 +- .../ui/public/new_platform/new_platform.ts | 14 +- .../ui/ui_bundles/ui_bundles_controller.js | 7 + src/legacy/ui/ui_render/ui_render_mixin.js | 33 +- src/legacy/ui/ui_render/views/ui_app.pug | 2 +- test/functional/page_objects/common_page.js | 21 +- .../core_plugin_a/public/application.tsx | 137 ++++++++ .../plugins/core_plugin_a/public/plugin.tsx | 9 + .../core_plugin_b/public/application.tsx | 144 +++++++++ .../plugins/core_plugin_b/public/plugin.tsx | 9 + .../test_suites/core_plugins/applications.js | 96 ++++++ .../test_suites/core_plugins/index.js | 1 + .../components/app/Main/UpdateBreadcrumbs.tsx | 4 +- .../ServiceIntegrations/WatcherFlyout.tsx | 4 +- .../__test__/ServiceOverview.test.tsx | 4 +- .../DiscoverLinks.integration.test.tsx | 4 +- .../shared/Links/InfraLink.test.tsx | 4 +- .../shared/Links/KibanaLink.test.tsx | 4 +- .../MachineLearningLinks/MLJobLink.test.tsx | 4 +- .../MachineLearningLinks/MLLink.test.tsx | 4 +- .../__test__/TransactionActionMenu.test.tsx | 4 +- .../apm/public/new-platform/plugin.tsx | 4 +- .../public/context/kibana_core.tsx | 4 +- x-pack/package.json | 2 +- yarn.lock | 10 +- 104 files changed, 2576 insertions(+), 442 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.app.md create mode 100644 docs/development/core/public/kibana-plugin-public.app.mount.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.capabilities.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.euiicontype.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.icon.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.id.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.order.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.title.md create mode 100644 docs/development/core/public/kibana-plugin-public.appbase.tooltip$.md rename docs/development/core/public/{kibana-plugin-public.applicationsetup.registerapp.md => kibana-plugin-public.applicationsetup.register.md} (52%) create mode 100644 docs/development/core/public/kibana-plugin-public.applicationsetup.registermountcontext.md delete mode 100644 docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md create mode 100644 docs/development/core/public/kibana-plugin-public.applicationstart.geturlforapp.md create mode 100644 docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md create mode 100644 docs/development/core/public/kibana-plugin-public.applicationstart.registermountcontext.md create mode 100644 docs/development/core/public/kibana-plugin-public.appmountcontext.core.md create mode 100644 docs/development/core/public/kibana-plugin-public.appmountcontext.md create mode 100644 docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md create mode 100644 docs/development/core/public/kibana-plugin-public.appmountparameters.element.md create mode 100644 docs/development/core/public/kibana-plugin-public.appmountparameters.md create mode 100644 docs/development/core/public/kibana-plugin-public.appunmount.md create mode 100644 docs/development/core/public/kibana-plugin-public.coresetup.application.md create mode 100644 docs/development/core/public/kibana-plugin-public.legacycoresetup.injectedmetadata.md create mode 100644 docs/development/core/public/kibana-plugin-public.legacycoresetup.md create mode 100644 docs/development/core/public/kibana-plugin-public.legacycorestart.injectedmetadata.md create mode 100644 docs/development/core/public/kibana-plugin-public.legacycorestart.md create mode 100644 src/core/public/application/integration_tests/router.test.tsx create mode 100644 src/core/public/application/types.ts create mode 100644 src/core/public/application/ui/app_container.tsx create mode 100644 src/core/public/application/ui/app_not_found_screen.tsx create mode 100644 src/core/public/application/ui/app_router.tsx create mode 100644 src/core/public/application/ui/index.ts create mode 100644 test/plugin_functional/plugins/core_plugin_a/public/application.tsx create mode 100644 test/plugin_functional/plugins/core_plugin_b/public/application.tsx create mode 100644 test/plugin_functional/test_suites/core_plugins/applications.js diff --git a/docs/development/core/public/kibana-plugin-public.app.md b/docs/development/core/public/kibana-plugin-public.app.md new file mode 100644 index 00000000000000..60cac357d1fe0c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) + +## App interface + +Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. + +Signature: + +```typescript +export interface App extends AppBase +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [mount](./kibana-plugin-public.app.mount.md) | (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise<AppUnmount> | A mount function called when the user navigates to this app's route. | + diff --git a/docs/development/core/public/kibana-plugin-public.app.mount.md b/docs/development/core/public/kibana-plugin-public.app.mount.md new file mode 100644 index 00000000000000..dda06b035db4a0 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.mount.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) > [mount](./kibana-plugin-public.app.mount.md) + +## App.mount property + +A mount function called when the user navigates to this app's route. + +Signature: + +```typescript +mount: (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.capabilities.md b/docs/development/core/public/kibana-plugin-public.appbase.capabilities.md new file mode 100644 index 00000000000000..450972e41bb299 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.capabilities.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [capabilities](./kibana-plugin-public.appbase.capabilities.md) + +## AppBase.capabilities property + +Custom capabilities defined by the app. + +Signature: + +```typescript +capabilities?: Partial; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.euiicontype.md b/docs/development/core/public/kibana-plugin-public.appbase.euiicontype.md new file mode 100644 index 00000000000000..99c7e852ff9052 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.euiicontype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) + +## AppBase.euiIconType property + +A EUI iconType that will be used for the app's icon. This icon takes precendence over the `icon` property. + +Signature: + +```typescript +euiIconType?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.icon.md b/docs/development/core/public/kibana-plugin-public.appbase.icon.md new file mode 100644 index 00000000000000..d94d0897bc5b75 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.icon.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [icon](./kibana-plugin-public.appbase.icon.md) + +## AppBase.icon property + +A URL to an image file used as an icon. Used as a fallback if `euiIconType` is not provided. + +Signature: + +```typescript +icon?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.id.md b/docs/development/core/public/kibana-plugin-public.appbase.id.md new file mode 100644 index 00000000000000..57daa0c94bdf6b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [id](./kibana-plugin-public.appbase.id.md) + +## AppBase.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.md b/docs/development/core/public/kibana-plugin-public.appbase.md new file mode 100644 index 00000000000000..338d30e780aaf0 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) + +## AppBase interface + + +Signature: + +```typescript +export interface AppBase +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [capabilities](./kibana-plugin-public.appbase.capabilities.md) | Partial<Capabilities> | Custom capabilities defined by the app. | +| [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | +| [icon](./kibana-plugin-public.appbase.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | +| [id](./kibana-plugin-public.appbase.id.md) | string | | +| [order](./kibana-plugin-public.appbase.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [title](./kibana-plugin-public.appbase.title.md) | string | The title of the application. | +| [tooltip$](./kibana-plugin-public.appbase.tooltip$.md) | Observable<string> | An observable for a tooltip shown when hovering over app link. | + diff --git a/docs/development/core/public/kibana-plugin-public.appbase.order.md b/docs/development/core/public/kibana-plugin-public.appbase.order.md new file mode 100644 index 00000000000000..dc0ea14a7b860b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.order.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [order](./kibana-plugin-public.appbase.order.md) + +## AppBase.order property + +An ordinal used to sort nav links relative to one another for display. + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.title.md b/docs/development/core/public/kibana-plugin-public.appbase.title.md new file mode 100644 index 00000000000000..4d0fb0c18e8143 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.title.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [title](./kibana-plugin-public.appbase.title.md) + +## AppBase.title property + +The title of the application. + +Signature: + +```typescript +title: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip$.md b/docs/development/core/public/kibana-plugin-public.appbase.tooltip$.md new file mode 100644 index 00000000000000..1b8ca490825f92 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.tooltip$.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip$](./kibana-plugin-public.appbase.tooltip$.md) + +## AppBase.tooltip$ property + +An observable for a tooltip shown when hovering over app link. + +Signature: + +```typescript +tooltip$?: Observable; +``` diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.md index a3ab77e43446c9..b53873bc0fb8ae 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.md @@ -15,5 +15,6 @@ export interface ApplicationSetup | Method | Description | | --- | --- | -| [registerApp(app)](./kibana-plugin-public.applicationsetup.registerapp.md) | Register an mountable application to the system. Apps will be mounted based on their rootRoute. | +| [register(app)](./kibana-plugin-public.applicationsetup.register.md) | Register an mountable application to the system. | +| [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. | diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerapp.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.register.md similarity index 52% rename from docs/development/core/public/kibana-plugin-public.applicationsetup.registerapp.md rename to docs/development/core/public/kibana-plugin-public.applicationsetup.register.md index f2532ae71ca2f6..b4ccb6a01c6001 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerapp.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.register.md @@ -1,22 +1,22 @@ -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerApp](./kibana-plugin-public.applicationsetup.registerapp.md) +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [register](./kibana-plugin-public.applicationsetup.register.md) -## ApplicationSetup.registerApp() method +## ApplicationSetup.register() method -Register an mountable application to the system. Apps will be mounted based on their `rootRoute`. +Register an mountable application to the system. Signature: ```typescript -registerApp(app: App): void; +register(app: App): void; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| app | App | | +| app | App | an [App](./kibana-plugin-public.app.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registermountcontext.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.registermountcontext.md new file mode 100644 index 00000000000000..0b5bd8eeb36eca --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.registermountcontext.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerMountContext](./kibana-plugin-public.applicationsetup.registermountcontext.md) + +## ApplicationSetup.registerMountContext() method + +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. + +Signature: + +```typescript +registerMountContext(contextName: T, provider: IContextProvider): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| contextName | T | The key of [AppMountContext](./kibana-plugin-public.appmountcontext.md) this provider's return value should be attached to. | +| provider | IContextProvider<AppMountContext, T> | A [IContextProvider](./kibana-plugin-public.icontextprovider.md) function | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md b/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md deleted file mode 100644 index 8bbd1dfcd31fad..00000000000000 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationStart](./kibana-plugin-public.applicationstart.md) > [availableApps](./kibana-plugin-public.applicationstart.availableapps.md) - -## ApplicationStart.availableApps property - -Apps available based on the current capabilities. Should be used to show navigation links and make routing decisions. - -Signature: - -```typescript -availableApps: readonly App[]; -``` diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.geturlforapp.md b/docs/development/core/public/kibana-plugin-public.applicationstart.geturlforapp.md new file mode 100644 index 00000000000000..422fbdf7418c23 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.geturlforapp.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationStart](./kibana-plugin-public.applicationstart.md) > [getUrlForApp](./kibana-plugin-public.applicationstart.geturlforapp.md) + +## ApplicationStart.getUrlForApp() method + +Returns a relative URL to a given app, including the global base path. + +Signature: + +```typescript +getUrlForApp(appId: string, options?: { + path?: string; + }): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appId | string | | +| options | {
path?: string;
} | | + +Returns: + +`string` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.md b/docs/development/core/public/kibana-plugin-public.applicationstart.md index 5854a7c65714eb..2a60ff449e44ea 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.md @@ -15,6 +15,13 @@ export interface ApplicationStart | Property | Type | Description | | --- | --- | --- | -| [availableApps](./kibana-plugin-public.applicationstart.availableapps.md) | readonly App[] | Apps available based on the current capabilities. Should be used to show navigation links and make routing decisions. | | [capabilities](./kibana-plugin-public.applicationstart.capabilities.md) | RecursiveReadonly<Capabilities> | Gets the read-only capabilities. | +## Methods + +| Method | Description | +| --- | --- | +| [getUrlForApp(appId, options)](./kibana-plugin-public.applicationstart.geturlforapp.md) | Returns a relative URL to a given app, including the global base path. | +| [navigateToApp(appId, options)](./kibana-plugin-public.applicationstart.navigatetoapp.md) | Navigiate to a given app | +| [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. | + diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md b/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md new file mode 100644 index 00000000000000..eef31fe661f54f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationStart](./kibana-plugin-public.applicationstart.md) > [navigateToApp](./kibana-plugin-public.applicationstart.navigatetoapp.md) + +## ApplicationStart.navigateToApp() method + +Navigiate to a given app + +Signature: + +```typescript +navigateToApp(appId: string, options?: { + path?: string; + state?: any; + }): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appId | string | | +| options | {
path?: string;
state?: any;
} | | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.registermountcontext.md b/docs/development/core/public/kibana-plugin-public.applicationstart.registermountcontext.md new file mode 100644 index 00000000000000..fc86aaf658b681 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.registermountcontext.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationStart](./kibana-plugin-public.applicationstart.md) > [registerMountContext](./kibana-plugin-public.applicationstart.registermountcontext.md) + +## ApplicationStart.registerMountContext() method + +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. + +Signature: + +```typescript +registerMountContext(contextName: T, provider: IContextProvider): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| contextName | T | The key of [AppMountContext](./kibana-plugin-public.appmountcontext.md) this provider's return value should be attached to. | +| provider | IContextProvider<AppMountContext, T> | A [IContextProvider](./kibana-plugin-public.icontextprovider.md) function | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-public.appmountcontext.core.md b/docs/development/core/public/kibana-plugin-public.appmountcontext.core.md new file mode 100644 index 00000000000000..63b3ead814f003 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appmountcontext.core.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppMountContext](./kibana-plugin-public.appmountcontext.md) > [core](./kibana-plugin-public.appmountcontext.core.md) + +## AppMountContext.core property + +Core service APIs available to mounted applications. + +Signature: + +```typescript +core: { + application: Pick; + chrome: ChromeStart; + docLinks: DocLinksStart; + http: HttpStart; + i18n: I18nStart; + notifications: NotificationsStart; + overlays: OverlayStart; + uiSettings: UiSettingsClientContract; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appmountcontext.md b/docs/development/core/public/kibana-plugin-public.appmountcontext.md new file mode 100644 index 00000000000000..c6541e3eca392f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appmountcontext.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppMountContext](./kibana-plugin-public.appmountcontext.md) + +## AppMountContext interface + +The context object received when applications are mounted to the DOM. + +Signature: + +```typescript +export interface AppMountContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [core](./kibana-plugin-public.appmountcontext.core.md) | {
application: Pick<ApplicationStart, 'capabilities' | 'navigateToApp'>;
chrome: ChromeStart;
docLinks: DocLinksStart;
http: HttpStart;
i18n: I18nStart;
notifications: NotificationsStart;
overlays: OverlayStart;
uiSettings: UiSettingsClientContract;
} | Core service APIs available to mounted applications. | + diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md new file mode 100644 index 00000000000000..16c8ffe07fc155 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md @@ -0,0 +1,53 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppMountParameters](./kibana-plugin-public.appmountparameters.md) > [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) + +## AppMountParameters.appBasePath property + +The base path for configuring the application's router. + +Signature: + +```typescript +appBasePath: string; +``` + +## Example + +How to configure react-router with a base path: + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + setup({ application }) { + application.register({ + id: 'my-app', + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); +} + +``` + +```ts +// application.tsx +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter, Route } from 'react-router-dom'; + +export renderApp = (context, { appBasePath, element }) => { + ReactDOM.render( + // pass `appBasePath` to `basename` + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.element.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.element.md new file mode 100644 index 00000000000000..dbe496c01c2150 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.element.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppMountParameters](./kibana-plugin-public.appmountparameters.md) > [element](./kibana-plugin-public.appmountparameters.element.md) + +## AppMountParameters.element property + +The container element to render the application into. + +Signature: + +```typescript +element: HTMLElement; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.md new file mode 100644 index 00000000000000..8733f9cd4915d3 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppMountParameters](./kibana-plugin-public.appmountparameters.md) + +## AppMountParameters interface + + +Signature: + +```typescript +export interface AppMountParameters +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The base path for configuring the application's router. | +| [element](./kibana-plugin-public.appmountparameters.element.md) | HTMLElement | The container element to render the application into. | + diff --git a/docs/development/core/public/kibana-plugin-public.appunmount.md b/docs/development/core/public/kibana-plugin-public.appunmount.md new file mode 100644 index 00000000000000..61782d19ca8c58 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appunmount.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUnmount](./kibana-plugin-public.appunmount.md) + +## AppUnmount type + +A function called when an application should be unmounted from the page. This function should be synchronous. + +Signature: + +```typescript +export declare type AppUnmount = () => void; +``` diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.order.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.order.md index e4e2ad2c7a3a7b..1fef9fc1dc359d 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.order.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.order.md @@ -9,5 +9,5 @@ An ordinal used to sort nav links relative to one another for display. Signature: ```typescript -readonly order: number; +readonly order?: number; ``` diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.application.md b/docs/development/core/public/kibana-plugin-public.coresetup.application.md new file mode 100644 index 00000000000000..4b39b2c76802b1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.coresetup.application.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [CoreSetup](./kibana-plugin-public.coresetup.md) > [application](./kibana-plugin-public.coresetup.application.md) + +## CoreSetup.application property + +[ApplicationSetup](./kibana-plugin-public.applicationsetup.md) + +Signature: + +```typescript +application: ApplicationSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.md b/docs/development/core/public/kibana-plugin-public.coresetup.md index a4b5b88df36dc4..9b94e2db528319 100644 --- a/docs/development/core/public/kibana-plugin-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-public.coresetup.md @@ -16,6 +16,7 @@ export interface CoreSetup | Property | Type | Description | | --- | --- | --- | +| [application](./kibana-plugin-public.coresetup.application.md) | ApplicationSetup | [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | [context](./kibana-plugin-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | | [http](./kibana-plugin-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-public.httpsetup.md) | diff --git a/docs/development/core/public/kibana-plugin-public.corestart.application.md b/docs/development/core/public/kibana-plugin-public.corestart.application.md index 1dd05ff947aeba..c26701ca80529a 100644 --- a/docs/development/core/public/kibana-plugin-public.corestart.application.md +++ b/docs/development/core/public/kibana-plugin-public.corestart.application.md @@ -9,5 +9,5 @@ Signature: ```typescript -application: Pick; +application: ApplicationStart; ``` diff --git a/docs/development/core/public/kibana-plugin-public.corestart.md b/docs/development/core/public/kibana-plugin-public.corestart.md index 446e4587352142..5c1626958c4df6 100644 --- a/docs/development/core/public/kibana-plugin-public.corestart.md +++ b/docs/development/core/public/kibana-plugin-public.corestart.md @@ -16,7 +16,7 @@ export interface CoreStart | Property | Type | Description | | --- | --- | --- | -| [application](./kibana-plugin-public.corestart.application.md) | Pick<ApplicationStart, 'capabilities'> | [ApplicationStart](./kibana-plugin-public.applicationstart.md) | +| [application](./kibana-plugin-public.corestart.application.md) | ApplicationStart | [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | [chrome](./kibana-plugin-public.corestart.chrome.md) | ChromeStart | [ChromeStart](./kibana-plugin-public.chromestart.md) | | [docLinks](./kibana-plugin-public.corestart.doclinks.md) | DocLinksStart | [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | [http](./kibana-plugin-public.corestart.http.md) | HttpStart | [HttpStart](./kibana-plugin-public.httpstart.md) | diff --git a/docs/development/core/public/kibana-plugin-public.legacycoresetup.injectedmetadata.md b/docs/development/core/public/kibana-plugin-public.legacycoresetup.injectedmetadata.md new file mode 100644 index 00000000000000..f71277e64ff17a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.legacycoresetup.injectedmetadata.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) > [injectedMetadata](./kibana-plugin-public.legacycoresetup.injectedmetadata.md) + +## LegacyCoreSetup.injectedMetadata property + +> Warning: This API is now obsolete. +> +> + +Signature: + +```typescript +injectedMetadata: InjectedMetadataSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-public.legacycoresetup.md b/docs/development/core/public/kibana-plugin-public.legacycoresetup.md new file mode 100644 index 00000000000000..f704bc65d12a52 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.legacycoresetup.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) + +## LegacyCoreSetup interface + +> Warning: This API is now obsolete. +> +> + +Setup interface exposed to the legacy platform via the `ui/new_platform` module. + +Signature: + +```typescript +export interface LegacyCoreSetup extends CoreSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [injectedMetadata](./kibana-plugin-public.legacycoresetup.injectedmetadata.md) | InjectedMetadataSetup | | + +## Remarks + +Some methods are not supported in the legacy platform and while present to make this type compatibile with [CoreSetup](./kibana-plugin-public.coresetup.md), unsupported methods will throw exceptions when called. + diff --git a/docs/development/core/public/kibana-plugin-public.legacycorestart.injectedmetadata.md b/docs/development/core/public/kibana-plugin-public.legacycorestart.injectedmetadata.md new file mode 100644 index 00000000000000..cd818c3f5adc72 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.legacycorestart.injectedmetadata.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) > [injectedMetadata](./kibana-plugin-public.legacycorestart.injectedmetadata.md) + +## LegacyCoreStart.injectedMetadata property + +> Warning: This API is now obsolete. +> +> + +Signature: + +```typescript +injectedMetadata: InjectedMetadataStart; +``` diff --git a/docs/development/core/public/kibana-plugin-public.legacycorestart.md b/docs/development/core/public/kibana-plugin-public.legacycorestart.md new file mode 100644 index 00000000000000..775c3fb1ffe3d1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.legacycorestart.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) + +## LegacyCoreStart interface + +> Warning: This API is now obsolete. +> +> + +Start interface exposed to the legacy platform via the `ui/new_platform` module. + +Signature: + +```typescript +export interface LegacyCoreStart extends CoreStart +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [injectedMetadata](./kibana-plugin-public.legacycorestart.injectedmetadata.md) | InjectedMetadataStart | | + +## Remarks + +Some methods are not supported in the legacy platform and while present to make this type compatibile with [CoreStart](./kibana-plugin-public.corestart.md), unsupported methods will throw exceptions when called. + diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index 5fda9f91593061..ccabdc62c5e7a2 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -23,8 +23,12 @@ The plugin integrates with the core system via lifecycle events: `setup` | Interface | Description | | --- | --- | +| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | +| [AppBase](./kibana-plugin-public.appbase.md) | | | [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | | [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | +| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. | +| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | | [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | | [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | | [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | @@ -54,6 +58,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | | | [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | | [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | | [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | @@ -80,6 +86,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Type Alias | Description | | --- | --- | +| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | | [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | | [HttpBody](./kibana-plugin-public.httpbody.md) | | diff --git a/package.json b/package.json index 3f095c8b822480..d7c1f3c8d32018 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "handlebars": "4.1.2", "hapi": "^17.5.3", "hapi-auth-cookie": "^9.0.0", + "history": "^4.9.0", "hjson": "3.1.2", "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", @@ -304,6 +305,7 @@ "@types/hapi": "^17.0.18", "@types/hapi-auth-cookie": "^9.1.0", "@types/has-ansi": "^3.0.0", + "@types/history": "^4.7.3", "@types/hoek": "^4.1.3", "@types/humps": "^1.1.2", "@types/jest": "^24.0.9", diff --git a/renovate.json5 b/renovate.json5 index e3b887d52149c5..7798909bd3f415 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -369,6 +369,14 @@ '@types/has-ansi', ], }, + { + groupSlug: 'history', + groupName: 'history related packages', + packageNames: [ + 'history', + '@types/history', + ], + }, { groupSlug: 'humps', groupName: 'humps related packages', @@ -617,14 +625,6 @@ '@types/git-url-parse', ], }, - { - groupSlug: 'history', - groupName: 'history related packages', - packageNames: [ - 'history', - '@types/history', - ], - }, { groupSlug: 'jsdom', groupName: 'jsdom related packages', diff --git a/rfcs/text/0004_application_service_mounting.md b/rfcs/text/0004_application_service_mounting.md index 30e8d9a05b8b4e..7dc577abc48e3d 100644 --- a/rfcs/text/0004_application_service_mounting.md +++ b/rfcs/text/0004_application_service_mounting.md @@ -18,14 +18,14 @@ import ReactDOM from 'react-dom'; import { MyApp } from './componnets'; -export function renderApp(context, targetDomElement) { +export function renderApp(context, { element }) { ReactDOM.render( , - targetDomElement + element ); return () => { - ReactDOM.unmountComponentAtNode(targetDomElement); + ReactDOM.unmountComponentAtNode(element); }; } ``` @@ -38,9 +38,9 @@ class MyPlugin { application.register({ id: 'my-app', title: 'My Application', - async mount(context, targetDomElement) { + async mount(context, params) { const { renderApp } = await import('./applcation'); - return renderApp(context, targetDomElement); + return renderApp(context, params); } }); } @@ -63,9 +63,7 @@ lock-in. ```ts /** A context type that implements the Handler Context pattern from RFC-0003 */ -export interface MountContext { - /** This is the base path for setting up your router. */ - basename: string; +export interface AppMountContext { /** These services serve as an example, but are subject to change. */ core: { http: { @@ -93,6 +91,13 @@ export interface MountContext { [contextName: string]: unknown; } +export interface AppMountParams { + /** The base path the application is mounted on. Used to configure routers. */ + appBasePath: string; + /** The element the application should render into */ + element: HTMLElement; +} + export type Unmount = () => Promise | void; export interface AppSpec { @@ -109,11 +114,11 @@ export interface AppSpec { /** * A mount function called when the user navigates to this app's route. - * @param context the `MountContext generated for this app - * @param targetDomElement An HTMLElement to mount the application onto. + * @param context the `AppMountContext` generated for this app + * @param params the `AppMountParams` * @returns An unmounting function that will be called to unmount the application. */ - mount(context: MountContext, targetDomElement: HTMLElement): Unmount | Promise; + mount(context: MountContext, params: AppMountParams): Unmount | Promise; /** * A EUI iconType that will be used for the app's icon. This icon @@ -158,19 +163,21 @@ When an app is registered via `register`, it must provide a `mount` function that will be invoked whenever the window's location has changed from another app to this app. -This function is called with a `MountContext` and an `HTMLElement` for the -application to render itself to. The mount function must also return a function -that can be called by the ApplicationService to unmount the application at the -given DOM node. The mount function may return a Promise of an unmount function -in order to import UI code dynamically. +This function is called with a `AppMountContext` and an +`AppMountParams` which contains a `HTMLElement` for the application to +render itself to. The mount function must also return a function that can be +called by the ApplicationService to unmount the application at the given DOM +Element. The mount function may return a Promise of an unmount function in order +to import UI code dynamically. The ApplicationService's `register` method will only be available during the *setup* lifecycle event. This allows the system to know when all applications have been registered. -The `mount` function will also get access to the `MountContext` that has many of -the same core services available during the `start` lifecycle. Plugins can also -register additional context attributes via the `registerMountContext` function. +The `mount` function will also get access to the `AppMountContext` that +has many of the same core services available during the `start` lifecycle. +Plugins can also register additional context attributes via the +`registerMountContext` function. ## Routing @@ -190,7 +197,7 @@ An example: "overview" page: mykibana.com/app/my-app/overview When setting up a router, your application should only handle the part of the -URL following the `context.basename` provided when you application is mounted. +URL following the `params.appBasePath` provided when you application is mounted. ### Legacy Applications @@ -211,7 +218,7 @@ a full-featured router and code-splitting. Note that using React or any other 3rd party tools featured here is not required to build a Kibana Application. ```tsx -// my_plugin/public/application.ts +// my_plugin/public/application.tsx import React from 'react'; import ReactDOM from 'react-dom'; @@ -239,16 +246,16 @@ const MyApp = ({ basename }) => ( , ); -export function renderApp(context, targetDomElement) { +export function renderApp(context, params) { ReactDOM.render( - // `context.basename` would be `/app/my-app` in this example. - // This exact string is not guaranteed to be stable, always reference - // `context.basename`. - , - targetDomElem + // `params.appBasePath` would be `/app/my-app` in this example. + // This exact string is not guaranteed to be stable, always reference the + // provided value at `params.appBasePath`. + , + params.element ); - return () => ReactDOM.unmountComponentAtNode(targetDomElem); + return () => ReactDOM.unmountComponentAtNode(params.element); } ``` @@ -259,9 +266,9 @@ export class MyPlugin { setup({ application }) { application.register({ id: 'my-app', - async mount(context, targetDomElem) { + async mount(context, params) { const { renderApp } = await import('./applcation'); - return renderApp(context, targetDomElement); + return renderApp(context, params); } }); } diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index 85d997f3dc9aad..a2db7552246362 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -17,23 +17,51 @@ * under the License. */ +import { Subject } from 'rxjs'; + import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; -import { ApplicationService, ApplicationSetup, ApplicationStart } from './application_service'; +import { ApplicationService } from './application_service'; +import { + ApplicationSetup, + InternalApplicationStart, + ApplicationStart, + InternalApplicationSetup, +} from './types'; type ApplicationServiceContract = PublicMethodsOf; const createSetupContractMock = (): jest.Mocked => ({ - registerApp: jest.fn(), + register: jest.fn(), + registerMountContext: jest.fn(), +}); + +const createInternalSetupContractMock = (): jest.Mocked => ({ + register: jest.fn(), registerLegacyApp: jest.fn(), + registerMountContext: jest.fn(), }); -const createStartContractMock = (): jest.Mocked => ({ - ...capabilitiesServiceMock.createStartContract(), +const createStartContractMock = (legacyMode = false): jest.Mocked => ({ + capabilities: capabilitiesServiceMock.createStartContract().capabilities, + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(), + registerMountContext: jest.fn(), +}); + +const createInternalStartContractMock = (): jest.Mocked => ({ + availableApps: new Map(), + availableLegacyApps: new Map(), + capabilities: capabilitiesServiceMock.createStartContract().capabilities, + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(), + registerMountContext: jest.fn(), + currentAppId$: new Subject(), + getComponent: jest.fn(), }); const createMock = (): jest.Mocked => ({ - setup: jest.fn().mockReturnValue(createSetupContractMock()), - start: jest.fn().mockReturnValue(createStartContractMock()), + setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), + start: jest.fn().mockReturnValue(createInternalStartContractMock()), stop: jest.fn(), }); @@ -41,4 +69,7 @@ export const applicationServiceMock = { create: createMock, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, + + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalStartContractMock, }; diff --git a/src/core/public/application/application_service.test.mocks.ts b/src/core/public/application/application_service.test.mocks.ts index c28d0a203068a8..d829cf18e56be2 100644 --- a/src/core/public/application/application_service.test.mocks.ts +++ b/src/core/public/application/application_service.test.mocks.ts @@ -26,3 +26,11 @@ export const CapabilitiesServiceConstructor = jest jest.doMock('./capabilities', () => ({ CapabilitiesService: CapabilitiesServiceConstructor, })); + +export const MockHistory = { + push: jest.fn(), +}; +export const createBrowserHistoryMock = jest.fn().mockReturnValue(MockHistory); +jest.doMock('history', () => ({ + createBrowserHistory: createBrowserHistoryMock, +})); diff --git a/src/core/public/application/application_service.test.tsx b/src/core/public/application/application_service.test.tsx index d2266671367a28..5b374218a59324 100644 --- a/src/core/public/application/application_service.test.tsx +++ b/src/core/public/application/application_service.test.tsx @@ -17,57 +17,219 @@ * under the License. */ +import { shallow } from 'enzyme'; +import React from 'react'; + import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; -import { MockCapabilitiesService } from './application_service.test.mocks'; +import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; import { ApplicationService } from './application_service'; +import { contextServiceMock } from '../context/context_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; + +describe('#setup()', () => { + describe('register', () => { + it('throws an error if two apps with the same id are registered', () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + setup.register(Symbol(), { id: 'app1' } as any); + expect(() => + setup.register(Symbol(), { id: 'app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app1\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + await service.start({ http, injectedMetadata }); + expect(() => + setup.register(Symbol(), { id: 'app1' } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); + }); + }); + + describe('registerLegacyApp', () => { + it('throws an error if two apps with the same id are registered', () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + setup.registerLegacyApp({ id: 'app2' } as any); + expect(() => + setup.registerLegacyApp({ id: 'app2' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"A legacy application is already registered with the id \\"app2\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + await service.start({ http, injectedMetadata }); + expect(() => + setup.registerLegacyApp({ id: 'app2' } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); + }); + }); + + it("`registerMountContext` calls context container's registerContext", () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + const container = context.createContextContainer.mock.results[0].value; + const pluginId = Symbol(); + const noop = () => {}; + setup.registerMountContext(pluginId, 'test' as any, noop as any); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', noop); + }); +}); describe('#start()', () => { + beforeEach(() => { + MockHistory.push.mockReset(); + }); + it('exposes available apps from capabilities', async () => { const service = new ApplicationService(); - const setup = service.setup(); - setup.registerApp({ id: 'app1' } as any); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + setup.register(Symbol(), { id: 'app1' } as any); setup.registerLegacyApp({ id: 'app2' } as any); + + const http = httpServiceMock.createStartContract(); const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - const startContract = await service.start({ injectedMetadata }); + const startContract = await service.start({ http, injectedMetadata }); + expect(startContract.availableApps).toMatchInlineSnapshot(` -Array [ - Object { - "id": "app1", - }, -] -`); + Map { + "app1" => Object { + "id": "app1", + }, + } + `); expect(startContract.availableLegacyApps).toMatchInlineSnapshot(` -Array [ - Object { - "id": "app2", - }, -] -`); + Map { + "app2" => Object { + "id": "app2", + }, + } + `); }); it('passes registered applications to capabilities', async () => { const service = new ApplicationService(); - const setup = service.setup(); - setup.registerApp({ id: 'app1' } as any); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); + setup.register(Symbol(), { id: 'app1' } as any); + + const http = httpServiceMock.createStartContract(); const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ injectedMetadata }); + await service.start({ http, injectedMetadata }); + expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: [{ id: 'app1' }], - legacyApps: [], + apps: new Map([['app1', { id: 'app1' }]]), + legacyApps: new Map(), injectedMetadata, }); }); it('passes registered legacy applications to capabilities', async () => { const service = new ApplicationService(); - const setup = service.setup(); + const context = contextServiceMock.createSetupContract(); + const setup = service.setup({ context }); setup.registerLegacyApp({ id: 'legacyApp1' } as any); + + const http = httpServiceMock.createStartContract(); const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ injectedMetadata }); + await service.start({ http, injectedMetadata }); + expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: [], - legacyApps: [{ id: 'legacyApp1' }], + apps: new Map(), + legacyApps: new Map([['legacyApp1', { id: 'legacyApp1' }]]), injectedMetadata, }); }); + + it('returns renderable JSX tree', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + service.setup({ context }); + + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + injectedMetadata.getLegacyMode.mockReturnValue(false); + const start = await service.start({ http, injectedMetadata }); + + expect(() => shallow(React.createElement(() => start.getComponent()))).not.toThrow(); + }); + + describe('navigateToApp', () => { + it('changes the browser history to /app/:appId', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + service.setup({ context }); + + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + injectedMetadata.getLegacyMode.mockReturnValue(false); + const start = await service.start({ http, injectedMetadata }); + + start.navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + start.navigateToApp('myOtherApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); + }); + + it('appends a path if specified', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + service.setup({ context }); + + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + injectedMetadata.getLegacyMode.mockReturnValue(false); + const start = await service.start({ http, injectedMetadata }); + + start.navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/app/myTestApp/deep/link/to/location/2', + undefined + ); + }); + + it('includes state if specified', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + service.setup({ context }); + + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + injectedMetadata.getLegacyMode.mockReturnValue(false); + const start = await service.start({ http, injectedMetadata }); + + start.navigateToApp('myTestApp', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); + }); + + it('redirects when in legacyMode', async () => { + const service = new ApplicationService(); + const context = contextServiceMock.createSetupContract(); + service.setup({ context }); + + const http = httpServiceMock.createStartContract(); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + injectedMetadata.getLegacyMode.mockReturnValue(true); + const redirectTo = jest.fn(); + const start = await service.start({ http, injectedMetadata, redirectTo }); + start.navigateToApp('myTestApp'); + expect(redirectTo).toHaveBeenCalledWith('/app/myTestApp'); + }); + }); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 528b81ad40be7c..d1855a0370f002 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -17,108 +17,43 @@ * under the License. */ -import { Observable, BehaviorSubject } from 'rxjs'; -import { CapabilitiesService, Capabilities } from './capabilities'; -import { InjectedMetadataStart } from '../injected_metadata'; -import { RecursiveReadonly } from '../../utils'; - -interface BaseApp { - id: string; - - /** - * An ordinal used to sort nav links relative to one another for display. - */ - order: number; - - /** - * The title of the application. - */ - title: string; - - /** - * An observable for a tooltip shown when hovering over app link. - */ - tooltip$?: Observable; - - /** - * A EUI iconType that will be used for the app's icon. This icon - * takes precendence over the `icon` property. - */ - euiIconType?: string; - - /** - * A URL to an image file used as an icon. Used as a fallback - * if `euiIconType` is not provided. - */ - icon?: string; - - /** - * Custom capabilities defined by the app. - */ - capabilities?: Partial; -} - -/** @public */ -export interface App extends BaseApp { - /** - * A mount function called when the user navigates to this app's `rootRoute`. - * @param targetDomElement An HTMLElement to mount the application onto. - * @returns An unmounting function that will be called to unmount the application. - */ - mount(targetDomElement: HTMLElement): () => void; -} - -/** @internal */ -export interface LegacyApp extends BaseApp { - appUrl: string; - subUrlBase?: string; - linkToLastSubUrl?: boolean; -} - -/** @internal */ -export type MixedApp = Partial & Partial & BaseApp; - -/** @public */ -export interface ApplicationSetup { - /** - * Register an mountable application to the system. Apps will be mounted based on their `rootRoute`. - * @param app - */ - registerApp(app: App): void; +import { createBrowserHistory } from 'history'; +import { BehaviorSubject } from 'rxjs'; +import React from 'react'; - /** - * Register metadata about legacy applications. Legacy apps will not be mounted when navigated to. - * @param app - * @internal - */ - registerLegacyApp(app: LegacyApp): void; +import { InjectedMetadataStart } from '../injected_metadata'; +import { CapabilitiesService } from './capabilities'; +import { AppRouter } from './ui'; +import { HttpStart } from '../http'; +import { ContextSetup, IContextContainer } from '../context'; +import { + AppMountContext, + App, + LegacyApp, + AppMounter, + AppUnmount, + AppMountParameters, + InternalApplicationSetup, + InternalApplicationStart, +} from './types'; + +interface SetupDeps { + context: ContextSetup; } -/** - * @public - */ -export interface ApplicationStart { - /** - * Gets the read-only capabilities. - */ - capabilities: RecursiveReadonly; - - /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - */ - availableApps: readonly App[]; - +interface StartDeps { + http: HttpStart; + injectedMetadata: InjectedMetadataStart; /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - * @internal + * Only necessary for redirecting to legacy apps + * @deprecated */ - availableLegacyApps: readonly LegacyApp[]; + redirectTo?: (path: string) => void; } -interface StartDeps { - injectedMetadata: InjectedMetadataStart; +interface AppBox { + app: App; + mount: AppMounter; } /** @@ -126,31 +61,122 @@ interface StartDeps { * @internal */ export class ApplicationService { - private readonly apps$ = new BehaviorSubject([]); - private readonly legacyApps$ = new BehaviorSubject([]); + private readonly apps$ = new BehaviorSubject>(new Map()); + private readonly legacyApps$ = new BehaviorSubject>(new Map()); private readonly capabilities = new CapabilitiesService(); + private mountContext?: IContextContainer< + AppMountContext, + AppUnmount | Promise, + [AppMountParameters] + >; + + public setup({ context }: SetupDeps): InternalApplicationSetup { + this.mountContext = context.createContextContainer(); - public setup(): ApplicationSetup { return { - registerApp: (app: App) => { - this.apps$.next([...this.apps$.value, app]); + register: (plugin: symbol, app: App) => { + if (this.apps$.value.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); + } + if (this.apps$.isStopped) { + throw new Error(`Applications cannot be registered after "setup"`); + } + + const appBox: AppBox = { + app, + mount: this.mountContext!.createHandler(plugin, app.mount), + }; + this.apps$.next(new Map([...this.apps$.value.entries(), [app.id, appBox]])); }, registerLegacyApp: (app: LegacyApp) => { - this.legacyApps$.next([...this.legacyApps$.value, app]); + if (this.legacyApps$.value.has(app.id)) { + throw new Error(`A legacy application is already registered with the id "${app.id}"`); + } + if (this.legacyApps$.isStopped) { + throw new Error(`Applications cannot be registered after "setup"`); + } + + this.legacyApps$.next(new Map([...this.legacyApps$.value.entries(), [app.id, app]])); }, + registerMountContext: this.mountContext.registerContext, }; } - public async start({ injectedMetadata }: StartDeps): Promise { + public async start({ + http, + injectedMetadata, + redirectTo = (path: string) => (window.location.href = path), + }: StartDeps): Promise { + if (!this.mountContext) { + throw new Error(`ApplicationService#setup() must be invoked before start.`); + } + + // Disable registration of new applications this.apps$.complete(); this.legacyApps$.complete(); - return this.capabilities.start({ - apps: this.apps$.value, + const legacyMode = injectedMetadata.getLegacyMode(); + const currentAppId$ = new BehaviorSubject(undefined); + const { availableApps, availableLegacyApps, capabilities } = await this.capabilities.start({ + apps: new Map([...this.apps$.value].map(([id, { app }]) => [id, app])), legacyApps: this.legacyApps$.value, injectedMetadata, }); + + // Only setup history if we're not in legacy mode + const history = legacyMode ? null : createBrowserHistory({ basename: http.basePath.get() }); + + return { + availableApps, + availableLegacyApps, + capabilities, + registerMountContext: this.mountContext.registerContext, + currentAppId$, + + getUrlForApp: (appId, options: { path?: string } = {}) => { + return http.basePath.prepend(appPath(appId, options)); + }, + + navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => { + if (legacyMode) { + // If we're in legacy mode, do a full page refresh to load the NP app. + redirectTo(http.basePath.prepend(appPath(appId, { path }))); + } else { + // basePath not needed here because `history` is configured with basename + history!.push(appPath(appId, { path }), state); + } + }, + + getComponent: () => { + if (legacyMode) { + return null; + } + + // Filter only available apps and map to just the mount function. + const appMounters = new Map( + [...this.apps$.value] + .filter(([id]) => availableApps.has(id)) + .map(([id, { mount }]) => [id, mount]) + ); + + return ( + + ); + }, + }; } public stop() {} } + +const appPath = (appId: string, { path }: { path?: string } = {}): string => + path + ? `/app/${appId}/${path.replace(/^\//, '')}` // Remove preceding slash from path if present + : `/app/${appId}`; diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 71b069fd80434e..29c3275f0e3b27 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -18,11 +18,11 @@ */ import { CapabilitiesService, CapabilitiesStart } from './capabilities_service'; import { deepFreeze } from '../../../utils/'; -import { App, LegacyApp } from '../application_service'; +import { App, LegacyApp } from '../types'; const createStartContractMock = ( - apps: readonly App[] = [], - legacyApps: readonly LegacyApp[] = [] + apps: ReadonlyMap = new Map(), + legacyApps: ReadonlyMap = new Map() ): jest.Mocked => ({ availableApps: apps, availableLegacyApps: legacyApps, diff --git a/src/core/public/application/capabilities/capabilities_service.test.ts b/src/core/public/application/capabilities/capabilities_service.test.ts index 1c60c1eeb195aa..e80e9a7af321a8 100644 --- a/src/core/public/application/capabilities/capabilities_service.test.ts +++ b/src/core/public/application/capabilities/capabilities_service.test.ts @@ -19,6 +19,7 @@ import { InjectedMetadataService } from '../../injected_metadata'; import { CapabilitiesService } from './capabilities_service'; +import { LegacyApp, App } from '../types'; describe('#start', () => { const injectedMetadata = new InjectedMetadataService({ @@ -39,17 +40,22 @@ describe('#start', () => { } as any, }).start(); - const apps = [{ id: 'app1' }, { id: 'app2', capabilities: { app2: { feature: true } } }] as any; - const legacyApps = [ - { id: 'legacyApp1' }, - { id: 'legacyApp2', capabilities: { app2: { feature: true } } }, - ] as any; + const apps = new Map([ + ['app1', { id: 'app1' }], + ['app2', { id: 'app2', capabilities: { app2: { feature: true } } }], + ] as Array<[string, App]>); + const legacyApps = new Map([ + ['legacyApp1', { id: 'legacyApp1' }], + ['legacyApp2', { id: 'legacyApp2', capabilities: { app2: { feature: true } } }], + ] as Array<[string, LegacyApp]>); it('filters available apps based on returned navLinks', async () => { const service = new CapabilitiesService(); const startContract = await service.start({ apps, legacyApps, injectedMetadata }); - expect(startContract.availableApps).toEqual([{ id: 'app1' }]); - expect(startContract.availableLegacyApps).toEqual([{ id: 'legacyApp1' }]); + expect(startContract.availableApps).toEqual(new Map([['app1', { id: 'app1' }]])); + expect(startContract.availableLegacyApps).toEqual( + new Map([['legacyApp1', { id: 'legacyApp1' }]]) + ); }); it('does not allow Capabilities to be modified', async () => { diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index 51c5a218e70bd3..b080f8c138cf28 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -18,12 +18,12 @@ */ import { deepFreeze, RecursiveReadonly } from '../../../utils'; -import { LegacyApp, App } from '../application_service'; +import { LegacyApp, App } from '../types'; import { InjectedMetadataStart } from '../../injected_metadata'; interface StartDeps { - apps: readonly App[]; - legacyApps: readonly LegacyApp[]; + apps: ReadonlyMap; + legacyApps: ReadonlyMap; injectedMetadata: InjectedMetadataStart; } @@ -53,8 +53,8 @@ export interface Capabilities { /** @internal */ export interface CapabilitiesStart { capabilities: RecursiveReadonly; - availableApps: readonly App[]; - availableLegacyApps: readonly LegacyApp[]; + availableApps: ReadonlyMap; + availableLegacyApps: ReadonlyMap; } /** @@ -68,10 +68,23 @@ export class CapabilitiesService { injectedMetadata, }: StartDeps): Promise { const capabilities = deepFreeze(injectedMetadata.getCapabilities()); + const availableApps = new Map( + [...apps].filter( + ([appId]) => + capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true + ) + ); + + const availableLegacyApps = new Map( + [...legacyApps].filter( + ([appId]) => + capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true + ) + ); return { - availableApps: apps.filter(app => capabilities.navLinks[app.id]), - availableLegacyApps: legacyApps.filter(app => capabilities.navLinks[app.id]), + availableApps, + availableLegacyApps, capabilities, }; } diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 137b46e6573e6d..ae25b54cf07a84 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -17,5 +17,17 @@ * under the License. */ -export { ApplicationService, ApplicationSetup, ApplicationStart } from './application_service'; +export { ApplicationService } from './application_service'; export { Capabilities } from './capabilities'; +export { + App, + AppBase, + AppUnmount, + AppMountContext, + AppMountParameters, + ApplicationSetup, + ApplicationStart, + // Internal types + InternalApplicationStart, + LegacyApp, +} from './types'; diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx new file mode 100644 index 00000000000000..e6a1070e1a6847 --- /dev/null +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -0,0 +1,130 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { mount, ReactWrapper } from 'enzyme'; +import { createMemoryHistory, History } from 'history'; +import { BehaviorSubject } from 'rxjs'; + +import { I18nProvider } from '@kbn/i18n/react'; + +import { AppMounter, LegacyApp, AppMountParameters } from '../types'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { AppRouter, AppNotFound } from '../ui'; + +const createMountHandler = (htmlString: string) => + jest.fn(async ({ appBasePath: basename, element: el }: AppMountParameters) => { + ReactDOM.render( +
, + el + ); + return jest.fn(() => ReactDOM.unmountComponentAtNode(el)); + }); + +describe('AppContainer', () => { + let apps: Map, Parameters>>; + let legacyApps: Map; + let history: History; + let router: ReactWrapper; + let redirectTo: jest.Mock; + let currentAppId$: BehaviorSubject; + + const navigate = async (path: string) => { + history.push(path); + router.update(); + // flushes any pending promises + return new Promise(resolve => setImmediate(resolve)); + }; + + beforeEach(() => { + redirectTo = jest.fn(); + apps = new Map([ + ['app1', createMountHandler('App 1')], + ['app2', createMountHandler('
App 2
')], + ]); + legacyApps = new Map([ + ['legacyApp1', { id: 'legacyApp1' }], + ['baseApp:legacyApp2', { id: 'baseApp:legacyApp2' }], + ]) as Map; + history = createMemoryHistory(); + currentAppId$ = new BehaviorSubject(undefined); + // Use 'asdf' as the basepath + const http = httpServiceMock.createStartContract({ basePath: '/asdf' }); + router = mount( + + + + ); + }); + + it('calls mountHandler and returned unmount function when navigating between apps', async () => { + await navigate('/app/app1'); + expect(apps.get('app1')!).toHaveBeenCalled(); + expect(router.html()).toMatchInlineSnapshot(` + "
+ basename: /asdf/app/app1 + html: App 1 +
" + `); + + const app1Unmount = await apps.get('app1')!.mock.results[0].value; + await navigate('/app/app2'); + expect(app1Unmount).toHaveBeenCalled(); + + expect(apps.get('app2')!).toHaveBeenCalled(); + expect(router.html()).toMatchInlineSnapshot(` + "
+ basename: /asdf/app/app2 + html:
App 2
+
" + `); + }); + + it('updates currentApp$ after mounting', async () => { + await navigate('/app/app1'); + expect(currentAppId$.value).toEqual('app1'); + await navigate('/app/app2'); + expect(currentAppId$.value).toEqual('app2'); + }); + + it('sets window.location.href when navigating to legacy apps', async () => { + await navigate('/app/legacyApp1'); + expect(redirectTo).toHaveBeenCalledWith('/asdf/app/legacyApp1'); + }); + + it('handles legacy apps with subapps', async () => { + await navigate('/app/baseApp'); + expect(redirectTo).toHaveBeenCalledWith('/asdf/app/baseApp'); + }); + + it('displays error page if no app is found', async () => { + await navigate('/app/unknown'); + expect(router.exists(AppNotFound)).toBe(true); + }); +}); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts new file mode 100644 index 00000000000000..018d7569ce411b --- /dev/null +++ b/src/core/public/application/types.ts @@ -0,0 +1,300 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, Subject } from 'rxjs'; + +import { Capabilities } from './capabilities'; +import { ChromeStart } from '../chrome'; +import { IContextProvider } from '../context'; +import { DocLinksStart } from '../doc_links'; +import { HttpStart } from '../http'; +import { I18nStart } from '../i18n'; +import { NotificationsStart } from '../notifications'; +import { OverlayStart } from '../overlays'; +import { PluginOpaqueId } from '../plugins'; +import { UiSettingsClientContract } from '../ui_settings'; +import { RecursiveReadonly } from '../../utils'; + +/** @public */ +export interface AppBase { + id: string; + + /** + * The title of the application. + */ + title: string; + + /** + * An ordinal used to sort nav links relative to one another for display. + */ + order?: number; + + /** + * An observable for a tooltip shown when hovering over app link. + */ + tooltip$?: Observable; + + /** + * A EUI iconType that will be used for the app's icon. This icon + * takes precendence over the `icon` property. + */ + euiIconType?: string; + + /** + * A URL to an image file used as an icon. Used as a fallback + * if `euiIconType` is not provided. + */ + icon?: string; + + /** + * Custom capabilities defined by the app. + */ + capabilities?: Partial; +} + +/** + * Extension of {@link AppBase | common app properties} with the mount function. + * @public + */ +export interface App extends AppBase { + /** + * A mount function called when the user navigates to this app's route. + * @param context The mount context for this app. + * @param targetDomElement An HTMLElement to mount the application onto. + * @returns An unmounting function that will be called to unmount the application. + */ + mount: (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; +} + +/** @internal */ +export interface LegacyApp extends AppBase { + appUrl: string; + subUrlBase?: string; + linkToLastSubUrl?: boolean; +} + +/** + * The context object received when applications are mounted to the DOM. + * @public + */ +export interface AppMountContext { + /** + * Core service APIs available to mounted applications. + */ + core: { + /** {@link ApplicationStart} */ + application: Pick; + /** {@link ChromeStart} */ + chrome: ChromeStart; + /** {@link DocLinksStart} */ + docLinks: DocLinksStart; + /** {@link HttpStart} */ + http: HttpStart; + /** {@link I18nStart} */ + i18n: I18nStart; + /** {@link NotificationsStart} */ + notifications: NotificationsStart; + /** {@link OverlayStart} */ + overlays: OverlayStart; + /** {@link UiSettingsClient} */ + uiSettings: UiSettingsClientContract; + }; +} + +/** @public */ +export interface AppMountParameters { + /** + * The container element to render the application into. + */ + element: HTMLElement; + + /** + * The base path for configuring the application's router. + * + * @example + * + * How to configure react-router with a base path: + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * setup({ application }) { + * application.register({ + * id: 'my-app', + * async mount(context, params) { + * const { renderApp } = await import('./application'); + * return renderApp(context, params); + * }, + * }); + * } + * ``` + * + * ```ts + * // application.tsx + * import React from 'react'; + * import ReactDOM from 'react-dom'; + * import { BrowserRouter, Route } from 'react-router-dom'; + * + * export renderApp = (context, { appBasePath, element }) => { + * ReactDOM.render( + * // pass `appBasePath` to `basename` + * + * + * , + * element + * ); + * + * return () => ReactDOM.unmountComponentAtNode(element); + * } + * ``` + */ + appBasePath: string; +} + +/** + * A function called when an application should be unmounted from the page. This function should be synchronous. + * @public + */ +export type AppUnmount = () => void; + +/** @internal */ +export type AppMounter = (params: AppMountParameters) => Promise; + +/** @public */ +export interface ApplicationSetup { + /** + * Register an mountable application to the system. + * @param app - an {@link App} + */ + register(app: App): void; + + /** + * Register a context provider for application mounting. Will only be available to applications that depend on the + * plugin that registered this context. + * + * @param contextName - The key of {@link AppMountContext} this provider's return value should be attached to. + * @param provider - A {@link IContextProvider} function + */ + registerMountContext( + contextName: T, + provider: IContextProvider + ): void; +} + +/** @internal */ +export interface InternalApplicationSetup { + /** + * Register an mountable application to the system. + * @param plugin - opaque ID of the plugin that registers this application + * @param app + */ + register(plugin: PluginOpaqueId, app: App): void; + + /** + * Register metadata about legacy applications. Legacy apps will not be mounted when navigated to. + * @param app + * @internal + */ + registerLegacyApp(app: LegacyApp): void; + + /** + * Register a context provider for application mounting. Will only be available to applications that depend on the + * plugin that registered this context. + * + * @param pluginOpaqueId - The opaque ID of the plugin that is registering the context. + * @param contextName - The key of {@link AppMountContext} this provider's return value should be attached to. + * @param provider - A {@link IContextProvider} function + */ + registerMountContext( + pluginOpaqueId: PluginOpaqueId, + contextName: T, + provider: IContextProvider + ): void; +} + +/** @public */ +export interface ApplicationStart { + /** + * Gets the read-only capabilities. + */ + capabilities: RecursiveReadonly; + + /** + * Navigiate to a given app + * + * @param appId + * @param options.path - optional path inside application to deep link to + * @param options.state - optional state to forward to the application + */ + navigateToApp(appId: string, options?: { path?: string; state?: any }): void; + + /** + * Returns a relative URL to a given app, including the global base path. + * @param appId + * @param options.path - optional path inside application to deep link to + */ + getUrlForApp(appId: string, options?: { path?: string }): string; + + /** + * Register a context provider for application mounting. Will only be available to applications that depend on the + * plugin that registered this context. + * + * @param pluginOpaqueId - The opaque ID of the plugin that is registering the context. + * @param contextName - The key of {@link AppMountContext} this provider's return value should be attached to. + * @param provider - A {@link IContextProvider} function + */ + registerMountContext( + contextName: T, + provider: IContextProvider + ): void; +} + +/** @internal */ +export interface InternalApplicationStart + extends Pick { + /** + * Apps available based on the current capabilities. Should be used + * to show navigation links and make routing decisions. + */ + availableApps: ReadonlyMap; + /** + * Apps available based on the current capabilities. Should be used + * to show navigation links and make routing decisions. + * @internal + */ + availableLegacyApps: ReadonlyMap; + + /** + * Register a context provider for application mounting. Will only be available to applications that depend on the + * plugin that registered this context. + * + * @param pluginOpaqueId - The opaque ID of the plugin that is registering the context. + * @param contextName - The key of {@link AppMountContext} this provider's return value should be attached to. + * @param provider - A {@link IContextProvider} function + */ + registerMountContext( + pluginOpaqueId: PluginOpaqueId, + contextName: T, + provider: IContextProvider + ): void; + + // Internal APIs + currentAppId$: Subject; + getComponent(): JSX.Element | null; +} diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx new file mode 100644 index 00000000000000..876cd3aa3a3d3a --- /dev/null +++ b/src/core/public/application/ui/app_container.tsx @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { Subject } from 'rxjs'; + +import { LegacyApp, AppMounter, AppUnmount } from '../types'; +import { HttpStart } from '../../http'; +import { AppNotFound } from './app_not_found_screen'; + +interface Props extends RouteComponentProps<{ appId: string }> { + apps: ReadonlyMap; + legacyApps: ReadonlyMap; + basePath: HttpStart['basePath']; + currentAppId$: Subject; + /** + * Only necessary for redirecting to legacy apps + * @deprecated + */ + redirectTo: (path: string) => void; +} + +interface State { + appNotFound: boolean; +} + +export class AppContainer extends React.Component { + private readonly containerDiv = React.createRef(); + private unmountFunc?: AppUnmount; + + state: State = { appNotFound: false }; + + componentDidMount() { + this.mountApp(); + } + + componentWillUnmount() { + this.unmountApp(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.match.params.appId !== this.props.match.params.appId) { + this.unmountApp(); + this.mountApp(); + } + } + + async mountApp() { + const { apps, legacyApps, match, basePath, currentAppId$, redirectTo } = this.props; + const { appId } = match.params; + + const mount = apps.get(appId); + if (mount) { + this.unmountFunc = await mount({ + appBasePath: basePath.prepend(`/app/${appId}`), + element: this.containerDiv.current!, + }); + currentAppId$.next(appId); + this.setState({ appNotFound: false }); + return; + } + + const legacyApp = findLegacyApp(appId, legacyApps); + if (legacyApp) { + this.unmountApp(); + redirectTo(basePath.prepend(`/app/${appId}`)); + this.setState({ appNotFound: false }); + return; + } + + this.setState({ appNotFound: true }); + } + + async unmountApp() { + if (this.unmountFunc) { + this.unmountFunc(); + this.unmountFunc = undefined; + } + } + + render() { + return ( + + {this.state.appNotFound && } +
+ + ); + } +} + +function findLegacyApp(appId: string, apps: ReadonlyMap) { + const matchingApps = [...apps.entries()].filter(([id]) => id.split(':')[0] === appId); + return matchingApps.length ? matchingApps[0][1] : null; +} diff --git a/src/core/public/application/ui/app_not_found_screen.tsx b/src/core/public/application/ui/app_not_found_screen.tsx new file mode 100644 index 00000000000000..73a999c5dbf162 --- /dev/null +++ b/src/core/public/application/ui/app_not_found_screen.tsx @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiEmptyPrompt, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const AppNotFound = () => ( + + + + + + + } + body={ +

+ +

+ } + /> +
+
+
+); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx new file mode 100644 index 00000000000000..9d8acf19785567 --- /dev/null +++ b/src/core/public/application/ui/app_router.tsx @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { History } from 'history'; +import React from 'react'; +import { Router, Route } from 'react-router-dom'; +import { Subject } from 'rxjs'; + +import { LegacyApp, AppMounter } from '../types'; +import { AppContainer } from './app_container'; +import { HttpStart } from '../../http'; + +interface Props { + apps: ReadonlyMap; + legacyApps: ReadonlyMap; + basePath: HttpStart['basePath']; + currentAppId$: Subject; + history: History; + /** + * Only necessary for redirecting to legacy apps + * @deprecated + */ + redirectTo?: (path: string) => void; +} + +export const AppRouter: React.StatelessComponent = ({ + history, + redirectTo = (path: string) => (window.location.href = path), + ...otherProps +}) => ( + + } + /> + +); diff --git a/src/core/public/application/ui/index.ts b/src/core/public/application/ui/index.ts new file mode 100644 index 00000000000000..7fa778740d3b41 --- /dev/null +++ b/src/core/public/application/ui/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { AppRouter } from './app_router'; +export { AppNotFound } from './app_not_found_screen'; diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 74f2a09b895dee..3775989c5126b4 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -27,7 +27,7 @@ import { const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { - getComponent: jest.fn(), + getHeaderComponent: jest.fn(), navLinks: { getNavLinks$: jest.fn(), has: jest.fn(), diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 392846f8433ba7..45e94040eeb4a3 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -38,7 +38,7 @@ const store = new Map(); function defaultStartDeps() { return { - application: applicationServiceMock.createStartContract(), + application: applicationServiceMock.createInternalStartContract(), docLinks: docLinksServiceMock.createStartContract(), http: httpServiceMock.createStartContract(), injectedMetadata: injectedMetadataServiceMock.createStartContract(), @@ -87,7 +87,7 @@ Array [ const start = await service.start(defaultStartDeps()); // Have to do some fanagling to get the type system and enzyme to accept this. // Don't capture the snapshot because it's 600+ lines long. - expect(shallow(React.createElement(() => start.getComponent()))).toBeDefined(); + expect(shallow(React.createElement(() => start.getHeaderComponent()))).toBeDefined(); }); }); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index d829a27260d271..02195c794d280d 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -27,7 +27,7 @@ import { IconType } from '@elastic/eui'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; -import { ApplicationStart } from '../application'; +import { InternalApplicationStart } from '../application'; import { HttpStart } from '../http'; import { ChromeNavLinks, NavLinksService } from './nav_links'; @@ -74,7 +74,7 @@ interface ConstructorParams { } interface StartDeps { - application: ApplicationStart; + application: InternalApplicationStart; docLinks: DocLinksStart; http: HttpStart; injectedMetadata: InjectedMetadataStart; @@ -84,14 +84,11 @@ interface StartDeps { /** @internal */ export class ChromeService { private readonly stop$ = new ReplaySubject(1); - private readonly browserSupportsCsp: boolean; private readonly navControls = new NavControlsService(); private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); - constructor({ browserSupportsCsp }: ConstructorParams) { - this.browserSupportsCsp = browserSupportsCsp; - } + constructor(private readonly params: ConstructorParams) {} public async start({ application, @@ -115,7 +112,7 @@ export class ChromeService { const navLinks = this.navLinks.start({ application, http }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); - if (!this.browserSupportsCsp && injectedMetadata.getCspConfig().warnLegacyBrowsers) { + if (!this.params.browserSupportsCsp && injectedMetadata.getCspConfig().warnLegacyBrowsers) { notifications.toasts.addWarning( i18n.translate('core.chrome.legacyBrowserWarning', { defaultMessage: 'Your browser does not meet the security requirements for Kibana.', @@ -128,11 +125,12 @@ export class ChromeService { navLinks, recentlyAccessed, - getComponent: () => ( + getHeaderComponent: () => (
([ + [ + 'legacyApp1', + { id: 'legacyApp1', order: 0, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, + ], + [ + 'legacyApp2', + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + }, + ], + ['legacyApp3', { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3' }], + ]), } as any; const mockHttp = { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 2250ec40f0f441..affc639faf0b85 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -21,11 +21,11 @@ import { sortBy } from 'lodash'; import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { NavLinkWrapper, ChromeNavLinkUpdateableFields, ChromeNavLink } from './nav_link'; -import { ApplicationStart } from '../../application'; +import { InternalApplicationStart } from '../../application'; import { HttpStart } from '../../http'; interface StartDeps { - application: ApplicationStart; + application: InternalApplicationStart; http: HttpStart; } @@ -99,10 +99,22 @@ export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { - const legacyAppLinks = application.availableLegacyApps.map( - app => + const appLinks = [...application.availableApps].map( + ([appId, app]) => [ - app.id, + appId, + new NavLinkWrapper({ + ...app, + legacy: false, + baseUrl: relativeToAbsolute(http.basePath.prepend(`/app/${appId}`)), + }), + ] as [string, NavLinkWrapper] + ); + + const legacyAppLinks = [...application.availableLegacyApps].map( + ([appId, app]) => + [ + appId, new NavLinkWrapper({ ...app, legacy: true, @@ -112,7 +124,7 @@ export class NavLinksService { ); const navLinks$ = new BehaviorSubject>( - new Map(legacyAppLinks) + new Map([...legacyAppLinks, ...appLinks]) ); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 4dc64c57fa244d..afd9f8e4a38209 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -65,6 +65,7 @@ import { } from '../..'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; +import { ApplicationStart, InternalApplicationStart } from '../../../application/types'; // Providing a buffer between the limit and the cut off index // protects from truncating just the last couple (6) characters @@ -115,13 +116,24 @@ function extendRecentlyAccessedHistoryItem( }; } -function extendNavLink(navLink: ChromeNavLink) { +function extendNavLink(navLink: ChromeNavLink, urlForApp: ApplicationStart['getUrlForApp']) { + if (navLink.legacy) { + return { + ...navLink, + href: navLink.url && !navLink.active ? navLink.url : navLink.baseUrl, + }; + } + return { ...navLink, - href: navLink.url && !navLink.active ? navLink.url : navLink.baseUrl, + href: urlForApp(navLink.id), }; } +function isModifiedEvent(event: MouseEvent) { + return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); +} + function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { let current = element; while (current) { @@ -149,6 +161,7 @@ export type HeaderProps = Pick>; interface Props { kibanaVersion: string; + application: InternalApplicationStart; appTitle$: Rx.Observable; badge$: Rx.Observable; breadcrumbs$: Rx.Observable; @@ -159,6 +172,7 @@ interface Props { recentlyAccessed$: Rx.Observable; forceAppSwitcherNavigation$: Rx.Observable; helpExtension$: Rx.Observable; + legacyMode: boolean; navControlsLeft$: Rx.Observable; navControlsRight$: Rx.Observable; intl: InjectedIntl; @@ -169,6 +183,7 @@ interface Props { interface State { appTitle: string; + currentAppId?: string; isVisible: boolean; navLinks: ReadonlyArray>; recentlyAccessed: ReadonlyArray>; @@ -203,7 +218,11 @@ class HeaderUI extends Component { this.props.navLinks$, this.props.recentlyAccessed$, // Types for combineLatest only handle up to 6 inferred types so we combine these two separately. - Rx.combineLatest(this.props.navControlsLeft$, this.props.navControlsRight$) + Rx.combineLatest( + this.props.navControlsLeft$, + this.props.navControlsRight$, + this.props.application.currentAppId$ + ) ).subscribe({ next: ([ appTitle, @@ -211,18 +230,21 @@ class HeaderUI extends Component { forceNavigation, navLinks, recentlyAccessed, - [navControlsLeft, navControlsRight], + [navControlsLeft, navControlsRight, currentAppId], ]) => { this.setState({ appTitle, isVisible, forceNavigation, - navLinks: navLinks.map(navLink => extendNavLink(navLink)), + navLinks: navLinks.map(navLink => + extendNavLink(navLink, this.props.application.getUrlForApp) + ), recentlyAccessed: recentlyAccessed.map(ra => extendRecentlyAccessedHistoryItem(navLinks, ra, this.props.basePath) ), navControlsLeft, navControlsRight, + currentAppId, }); }, }); @@ -263,6 +285,7 @@ class HeaderUI extends Component { public render() { const { + application, badge$, basePath, breadcrumbs$, @@ -272,9 +295,11 @@ class HeaderUI extends Component { kibanaDocLink, kibanaVersion, onIsLockedUpdate, + legacyMode, } = this.props; const { appTitle, + currentAppId, isVisible, navControlsLeft, navControlsRight, @@ -291,9 +316,26 @@ class HeaderUI extends Component { .map(navLink => ({ key: navLink.id, label: navLink.title, + + // Use href and onClick to support "open in new tab" and SPA navigation in the same link href: navLink.href, + onClick: (event: MouseEvent) => { + if ( + !legacyMode && // ignore when in legacy mode + !navLink.legacy && // ignore links to legacy apps + !event.defaultPrevented && // onClick prevented default + event.button === 0 && // ignore everything but left clicks + !isModifiedEvent(event) // ignore clicks with modifier keys + ) { + event.preventDefault(); + application.navigateToApp(navLink.id); + } + }, + + // Legacy apps use `active` property, NP apps should match the current app + isActive: navLink.active || currentAppId === navLink.id, isDisabled: navLink.disabled, - isActive: navLink.active, + iconType: navLink.euiIconType, icon: !navLink.euiIconType && navLink.icon ? ( diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 7310a8f33eba4e..895fc785b11b1c 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -272,7 +272,9 @@ describe('#start()', () => { await startCore(); expect(MockRenderingService.start).toHaveBeenCalledTimes(1); expect(MockRenderingService.start).toHaveBeenCalledWith({ + application: expect.any(Object), chrome: expect.any(Object), + injectedMetadata: expect.any(Object), targetDomElement: expect.any(HTMLElement), }); }); @@ -364,7 +366,7 @@ describe('LegacyPlatformService targetDomElement', () => { it('only mounts the element when start, after setting up the legacyPlatformService', async () => { const core = createCoreSystem(); - let targetDomElementInStart: HTMLElement | null; + let targetDomElementInStart: HTMLElement | undefined; MockLegacyPlatformService.start.mockImplementation(({ targetDomElement }) => { targetDomElementInStart = targetDomElement; }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 7782c93c7bbb1b..4eb16572d8fec1 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -20,23 +20,29 @@ import './core.css'; import { CoreId } from '../server'; -import { InternalCoreSetup, InternalCoreStart } from '.'; +import { CoreSetup, CoreStart } from '.'; import { ChromeService } from './chrome'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors'; import { HttpService } from './http'; import { I18nService } from './i18n'; -import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; +import { + InjectedMetadataParams, + InjectedMetadataService, + InjectedMetadataSetup, + InjectedMetadataStart, +} from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy'; import { NotificationsService } from './notifications'; import { OverlayService } from './overlays'; import { PluginsService } from './plugins'; import { UiSettingsService } from './ui_settings'; import { ApplicationService } from './application'; -import { mapToObject } from '../utils/'; +import { mapToObject, pick } from '../utils/'; import { DocLinksService } from './doc_links'; import { RenderingService } from './rendering'; import { SavedObjectsService } from './saved_objects/saved_objects_service'; import { ContextService } from './context'; +import { InternalApplicationSetup, InternalApplicationStart } from './application/types'; interface Params { rootDomElement: HTMLElement; @@ -51,6 +57,18 @@ export interface CoreContext { coreId: CoreId; } +/** @internal */ +export interface InternalCoreSetup extends Omit { + application: InternalApplicationSetup; + injectedMetadata: InjectedMetadataSetup; +} + +/** @internal */ +export interface InternalCoreStart extends Omit { + application: InternalApplicationStart; + injectedMetadata: InjectedMetadataStart; +} + /** * The CoreSystem is the root of the new platform, and setups all parts * of Kibana in the UI, including the LegacyPlatform which is managed @@ -77,6 +95,7 @@ export class CoreSystem { private readonly context: ContextService; private readonly rootDomElement: HTMLElement; + private readonly coreContext: CoreContext; private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { @@ -106,14 +125,14 @@ export class CoreSystem { this.savedObjects = new SavedObjectsService(); this.uiSettings = new UiSettingsService(); this.overlay = new OverlayService(); - this.application = new ApplicationService(); this.chrome = new ChromeService({ browserSupportsCsp }); this.docLinks = new DocLinksService(); this.rendering = new RenderingService(); + this.application = new ApplicationService(); - const core: CoreContext = { coreId: Symbol('core') }; - this.context = new ContextService(core); - this.plugins = new PluginsService(core, injectedMetadata.uiPlugins); + this.coreContext = { coreId: Symbol('core') }; + this.context = new ContextService(this.coreContext); + this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins); this.legacyPlatform = new LegacyPlatformService({ requireLegacyFiles, @@ -133,10 +152,10 @@ export class CoreSystem { const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); - const application = this.application.setup(); const pluginDependencies = this.plugins.getOpaqueIds(); const context = this.context.setup({ pluginDependencies }); + const application = this.application.setup({ context }); const core: InternalCoreSetup = { application, @@ -150,7 +169,11 @@ export class CoreSystem { // Services that do not expose contracts at setup const plugins = await this.plugins.setup(core); - await this.legacyPlatform.setup({ core, plugins: mapToObject(plugins.contracts) }); + + await this.legacyPlatform.setup({ + core, + plugins: mapToObject(plugins.contracts), + }); return { fatalErrors: this.fatalErrorsSetup }; } catch (error) { @@ -171,7 +194,7 @@ export class CoreSystem { const http = await this.http.start({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); - const application = await this.application.start({ injectedMetadata }); + const application = await this.application.start({ http, injectedMetadata }); const coreUiTargetDomElement = document.createElement('div'); coreUiTargetDomElement.id = 'kibana-body'; @@ -200,6 +223,17 @@ export class CoreSystem { }); const uiSettings = await this.uiSettings.start(); + application.registerMountContext(this.coreContext.coreId, 'core', () => ({ + application: pick(application, ['capabilities', 'navigateToApp']), + chrome, + docLinks, + http, + i18n, + notifications, + overlays, + uiSettings, + })); + const core: InternalCoreStart = { application, chrome, @@ -215,9 +249,12 @@ export class CoreSystem { const plugins = await this.plugins.start(core); const rendering = this.rendering.start({ + application, chrome, + injectedMetadata, targetDomElement: coreUiTargetDomElement, }); + await this.legacyPlatform.start({ core, plugins: mapToObject(plugins.contracts), diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 4ce84f8ab38d13..a94543414acfaa 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -25,7 +25,7 @@ type ServiceSetupMockType = jest.Mocked & { basePath: jest.Mocked; }; -const createServiceMock = (): ServiceSetupMockType => ({ +const createServiceMock = ({ basePath = '' } = {}): ServiceSetupMockType => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -35,8 +35,8 @@ const createServiceMock = (): ServiceSetupMockType => ({ delete: jest.fn(), options: jest.fn(), basePath: { - get: jest.fn(), - prepend: jest.fn(), + get: jest.fn(() => basePath), + prepend: jest.fn(path => `${basePath}${path}`), remove: jest.fn(), }, addLoadingCount: jest.fn(), @@ -46,22 +46,19 @@ const createServiceMock = (): ServiceSetupMockType => ({ removeAllInterceptors: jest.fn(), }); -const createSetupContractMock = createServiceMock; -const createStartContractMock = createServiceMock; - -const createMock = () => { +const createMock = ({ basePath = '' } = {}) => { const mocked: jest.Mocked> = { setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockReturnValue(createSetupContractMock()); - mocked.start.mockReturnValue(createSetupContractMock()); + mocked.setup.mockReturnValue(createServiceMock({ basePath })); + mocked.start.mockReturnValue(createServiceMock({ basePath })); return mocked; }; export const httpServiceMock = { create: createMock, - createSetupContract: createSetupContractMock, - createStartContract: createStartContractMock, + createSetupContract: createServiceMock, + createStartContract: createServiceMock, }; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index abc922ff97c1d5..89c309d8427d76 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -71,6 +71,9 @@ import { IContextContainer, IContextProvider, ContextSetup, IContextHandler } fr export { CoreContext, CoreSystem } from './core_system'; export { RecursiveReadonly } from '../utils'; + +export { App, AppBase, AppUnmount, AppMountContext, AppMountParameters } from './application'; + export { SavedObjectsBatchResponse, SavedObjectsBulkCreateObject, @@ -114,6 +117,8 @@ export { * https://github.com/Microsoft/web-build-tools/issues/1237 */ export interface CoreSetup { + /** {@link ApplicationSetup} */ + application: ApplicationSetup; /** {@link ContextSetup} */ context: ContextSetup; /** {@link FatalErrorsSetup} */ @@ -137,7 +142,7 @@ export interface CoreSetup { */ export interface CoreStart { /** {@link ApplicationStart} */ - application: Pick; + application: ApplicationStart; /** {@link ChromeStart} */ chrome: ChromeStart; /** {@link DocLinksStart} */ @@ -156,15 +161,33 @@ export interface CoreStart { uiSettings: UiSettingsClientContract; } -/** @internal */ -export interface InternalCoreSetup extends CoreSetup { - application: ApplicationSetup; +/** + * Setup interface exposed to the legacy platform via the `ui/new_platform` module. + * + * @remarks + * Some methods are not supported in the legacy platform and while present to make this type compatibile with + * {@link CoreSetup}, unsupported methods will throw exceptions when called. + * + * @public + * @deprecated + */ +export interface LegacyCoreSetup extends CoreSetup { + /** @deprecated */ injectedMetadata: InjectedMetadataSetup; } -/** @internal */ -export interface InternalCoreStart extends CoreStart { - application: ApplicationStart; +/** + * Start interface exposed to the legacy platform via the `ui/new_platform` module. + * + * @remarks + * Some methods are not supported in the legacy platform and while present to make this type compatibile with + * {@link CoreStart}, unsupported methods will throw exceptions when called. + * + * @public + * @deprecated + */ +export interface LegacyCoreStart extends CoreStart { + /** @deprecated */ injectedMetadata: InjectedMetadataStart; } diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index c4579bee3f1310..9e1d5aeec7ff49 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -25,6 +25,7 @@ const createSetupContractMock = () => { getKibanaBranch: jest.fn(), getCapabilities: jest.fn(), getCspConfig: jest.fn(), + getLegacyMode: jest.fn(), getLegacyMetadata: jest.fn(), getPlugins: jest.fn(), getInjectedVar: jest.fn(), @@ -34,6 +35,7 @@ const createSetupContractMock = () => { setupContract.getCapabilities.mockReturnValue({} as any); setupContract.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); setupContract.getKibanaVersion.mockReturnValue('kibanaVersion'); + setupContract.getLegacyMode.mockReturnValue(true); setupContract.getLegacyMetadata.mockReturnValue({ nav: [], uiSettings: { diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 9fbc9554855126..fa93d0f5288b4f 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -51,6 +51,7 @@ export interface InjectedMetadataParams { plugin: DiscoveredPlugin; }>; capabilities: Capabilities; + legacyMode: boolean; legacyMetadata: { app: unknown; translations: unknown; @@ -112,6 +113,10 @@ export class InjectedMetadataService { return this.state.uiPlugins; }, + getLegacyMode: () => { + return this.state.legacyMode; + }, + getLegacyMetadata: () => { return this.state.legacyMetadata; }, @@ -156,6 +161,8 @@ export interface InjectedMetadataSetup { id: string; plugin: DiscoveredPlugin; }>; + /** Indicates whether or not we are rendering a known legacy app. */ + getLegacyMode: () => boolean; getLegacyMetadata: () => { app: unknown; translations: unknown; diff --git a/src/core/public/legacy/legacy_service.test.ts b/src/core/public/legacy/legacy_service.test.ts index eb5b3e90f1a525..37e07af0a7da58 100644 --- a/src/core/public/legacy/legacy_service.test.ts +++ b/src/core/public/legacy/legacy_service.test.ts @@ -61,7 +61,7 @@ import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsMock } from '../saved_objects/saved_objects_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; -const applicationSetup = applicationServiceMock.createSetupContract(); +const applicationSetup = applicationServiceMock.createInternalSetupContract(); const contextSetup = contextServiceMock.createSetupContract(); const fatalErrorsSetup = fatalErrorsServiceMock.createSetupContract(); const httpSetup = httpServiceMock.createSetupContract(); @@ -88,7 +88,7 @@ const defaultSetupDeps = { plugins: {}, }; -const applicationStart = applicationServiceMock.createStartContract(); +const applicationStart = applicationServiceMock.createInternalStartContract(); const docLinksStart = docLinksServiceMock.createStartContract(); const httpStart = httpServiceMock.createStartContract(); const chromeStart = chromeServiceMock.createStartContract(); @@ -98,6 +98,7 @@ const notificationsStart = notificationServiceMock.createStartContract(); const overlayStart = overlayServiceMock.createStartContract(); const uiSettingsStart = uiSettingsServiceMock.createStartContract(); const savedObjectsStart = savedObjectsMock.createStartContract(); +const mockStorage = { getItem: jest.fn() } as any; const defaultStartDeps = { core: { @@ -112,6 +113,7 @@ const defaultStartDeps = { uiSettings: uiSettingsStart, savedObjects: savedObjectsStart, }, + lastSubUrlStorage: mockStorage, targetDomElement: document.createElement('div'), plugins: {}, }; @@ -132,12 +134,29 @@ describe('#setup()', () => { legacyPlatform.setup(defaultSetupDeps); expect(mockUiNewPlatformSetup).toHaveBeenCalledTimes(1); - expect(mockUiNewPlatformSetup).toHaveBeenCalledWith(defaultSetupDeps.core, {}); + expect(mockUiNewPlatformSetup).toHaveBeenCalledWith(expect.any(Object), {}); }); }); }); describe('#start()', () => { + it('fetches and sets legacy lastSubUrls', () => { + chromeStart.navLinks.getAll.mockReturnValue([ + { id: 'link1', baseUrl: 'http://wowza.com/app1', legacy: true } as any, + ]); + mockStorage.getItem.mockReturnValue('http://wowza.com/app1/subUrl'); + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.setup(defaultSetupDeps); + legacyPlatform.start({ ...defaultStartDeps, lastSubUrlStorage: mockStorage }); + + expect(chromeStart.navLinks.update).toHaveBeenCalledWith('link1', { + url: 'http://wowza.com/app1/subUrl', + }); + }); + it('initializes ui/new_platform with core APIs', () => { const legacyPlatform = new LegacyPlatformService({ ...defaultParams, @@ -147,7 +166,7 @@ describe('#start()', () => { legacyPlatform.start(defaultStartDeps); expect(mockUiNewPlatformStart).toHaveBeenCalledTimes(1); - expect(mockUiNewPlatformStart).toHaveBeenCalledWith(defaultStartDeps.core, {}); + expect(mockUiNewPlatformStart).toHaveBeenCalledWith(expect.any(Object), {}); }); describe('useLegacyTestHarness = false', () => { diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index 7d852773ad03fa..ba93cd7b6b5a71 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -18,7 +18,8 @@ */ import angular from 'angular'; -import { InternalCoreSetup, InternalCoreStart } from '../'; +import { InternalCoreSetup, InternalCoreStart } from '../core_system'; +import { LegacyCoreSetup, LegacyCoreStart } from '../'; /** @internal */ export interface LegacyPlatformParams { @@ -34,7 +35,8 @@ interface SetupDeps { interface StartDeps { core: InternalCoreStart; plugins: Record; - targetDomElement: HTMLElement; + lastSubUrlStorage?: Storage; + targetDomElement?: HTMLElement; } interface BootstrapModule { @@ -55,10 +57,7 @@ export class LegacyPlatformService { constructor(private readonly params: LegacyPlatformParams) {} public setup({ core, plugins }: SetupDeps) { - // Inject parts of the new platform into parts of the legacy platform - // so that legacy APIs/modules can mimic their new platform counterparts - require('ui/new_platform').__setup__(core, plugins); - + // Always register legacy apps, even if not in legacy mode. core.injectedMetadata.getLegacyMetadata().nav.forEach((navLink: any) => core.application.registerLegacyApp({ id: navLink.id, @@ -71,12 +70,57 @@ export class LegacyPlatformService { linkToLastSubUrl: navLink.linkToLastSubUrl, }) ); + + const legacyCore: LegacyCoreSetup = { + ...core, + application: { + register: notSupported(`core.application.register()`), + registerMountContext: notSupported(`core.application.registerMountContext()`), + }, + }; + + // Inject parts of the new platform into parts of the legacy platform + // so that legacy APIs/modules can mimic their new platform counterparts + if (core.injectedMetadata.getLegacyMode()) { + require('ui/new_platform').__setup__(legacyCore, plugins); + } } - public start({ core, targetDomElement, plugins }: StartDeps) { + public start({ + core, + targetDomElement, + plugins, + lastSubUrlStorage = window.sessionStorage, + }: StartDeps) { + // Initialize legacy sub urls + core.chrome.navLinks + .getAll() + .filter(link => link.legacy) + .forEach(navLink => { + const lastSubUrl = lastSubUrlStorage.getItem(`lastSubUrl:${navLink.baseUrl}`); + core.chrome.navLinks.update(navLink.id, { + url: lastSubUrl || navLink.url || navLink.baseUrl, + }); + }); + + // Only import and bootstrap legacy platform if we're in legacy mode. + if (!core.injectedMetadata.getLegacyMode()) { + return; + } + + const legacyCore: LegacyCoreStart = { + ...core, + application: { + capabilities: core.application.capabilities, + getUrlForApp: core.application.getUrlForApp, + navigateToApp: core.application.navigateToApp, + registerMountContext: notSupported(`core.application.registerMountContext()`), + }, + }; + // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts - require('ui/new_platform').__start__(core, plugins); + require('ui/new_platform').__start__(legacyCore, plugins); // Load the bootstrap module before loading the legacy platform files so that // the bootstrap module can modify the environment a bit first @@ -91,7 +135,8 @@ export class LegacyPlatformService { this.targetDomElement = targetDomElement; - this.bootstrapModule.bootstrap(this.targetDomElement); + // `targetDomElement` is always defined when in legacy mode + this.bootstrapModule.bootstrap(this.targetDomElement!); } public stop() { @@ -129,3 +174,7 @@ export class LegacyPlatformService { return require('ui/chrome'); } } + +const notSupported = (methodName: string) => (...args: any[]) => { + throw new Error(`${methodName} is not supported in the legacy platform.`); +}; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 0f3a01c793ae3e..7c99f69d6fd7ad 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -42,6 +42,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; function createCoreSetupMock() { const mock: MockedKeys = { + application: applicationServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 66cb7c4a1171e3..f4e25d27447bc2 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -76,7 +76,12 @@ export function createPluginSetupContext< plugin: PluginWrapper ): CoreSetup { return { - context: omit(deps.context, 'setCurrentPlugin'), + application: { + register: app => deps.application.register(plugin.opaqueId, app), + registerMountContext: (contextName, provider) => + deps.application.registerMountContext(plugin.opaqueId, contextName, provider), + }, + context: deps.context, fatalErrors: deps.fatalErrors, http: deps.http, notifications: deps.notifications, @@ -107,6 +112,10 @@ export function createPluginStartContext< return { application: { capabilities: deps.application.capabilities, + navigateToApp: deps.application.navigateToApp, + getUrlForApp: deps.application.getUrlForApp, + registerMountContext: (contextName, provider) => + deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, docLinks: deps.docLinks, http: deps.http, diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 2b689e45b4f1ad..d6411554e5f85b 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -72,7 +72,7 @@ beforeEach(() => { }, ]; mockSetupDeps = { - application: applicationServiceMock.createSetupContract(), + application: applicationServiceMock.createInternalSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), @@ -81,10 +81,11 @@ beforeEach(() => { uiSettings: uiSettingsServiceMock.createSetupContract(), }; mockSetupContext = { - ...omit(mockSetupDeps, 'application', 'injectedMetadata'), + ...omit(mockSetupDeps, 'injectedMetadata'), + application: expect.any(Object), }; mockStartDeps = { - application: applicationServiceMock.createStartContract(), + application: applicationServiceMock.createInternalStartContract(), docLinks: docLinksServiceMock.createStartContract(), http: httpServiceMock.createStartContract(), chrome: chromeServiceMock.createStartContract(), @@ -97,9 +98,7 @@ beforeEach(() => { }; mockStartContext = { ...omit(mockStartDeps, 'injectedMetadata'), - application: { - capabilities: mockStartDeps.application.capabilities, - }, + application: expect.any(Object), chrome: omit(mockStartDeps.chrome, 'getComponent'), }; diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 13a52d78d72fc0..1ab9d7f2fa9b29 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -26,7 +26,7 @@ import { createPluginSetupContext, createPluginStartContext, } from './plugin_context'; -import { InternalCoreSetup, InternalCoreStart } from '..'; +import { InternalCoreSetup, InternalCoreStart } from '../core_system'; /** @internal */ export type PluginsServiceSetupDeps = InternalCoreSetup; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 060188608b8607..552476425d8d88 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -11,24 +11,65 @@ import React from 'react'; import * as Rx from 'rxjs'; import { EuiGlobalToastListToast as Toast } from '@elastic/eui'; +// @public +export interface App extends AppBase { + mount: (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; +} + +// @public (undocumented) +export interface AppBase { + capabilities?: Partial; + euiIconType?: string; + icon?: string; + // (undocumented) + id: string; + order?: number; + title: string; + tooltip$?: Observable; +} + // @public (undocumented) export interface ApplicationSetup { - // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts - registerApp(app: App): void; - // Warning: (ae-forgotten-export) The symbol "LegacyApp" needs to be exported by the entry point index.d.ts - // - // @internal - registerLegacyApp(app: LegacyApp): void; + register(app: App): void; + registerMountContext(contextName: T, provider: IContextProvider): void; } // @public (undocumented) export interface ApplicationStart { - availableApps: readonly App[]; - // @internal - availableLegacyApps: readonly LegacyApp[]; capabilities: RecursiveReadonly; + getUrlForApp(appId: string, options?: { + path?: string; + }): string; + navigateToApp(appId: string, options?: { + path?: string; + state?: any; + }): void; + registerMountContext(contextName: T, provider: IContextProvider): void; +} + +// @public +export interface AppMountContext { + core: { + application: Pick; + chrome: ChromeStart; + docLinks: DocLinksStart; + http: HttpStart; + i18n: I18nStart; + notifications: NotificationsStart; + overlays: OverlayStart; + uiSettings: UiSettingsClientContract; + }; +} + +// @public (undocumented) +export interface AppMountParameters { + appBasePath: string; + element: HTMLElement; } +// @public +export type AppUnmount = () => void; + // @public export interface Capabilities { [key: string]: Record>; @@ -105,7 +146,7 @@ export interface ChromeNavLink { readonly legacy: boolean; // @deprecated readonly linkToLastSubUrl?: boolean; - readonly order: number; + readonly order?: number; // @deprecated readonly subUrlBase?: string; readonly title: string; @@ -185,6 +226,8 @@ export interface CoreContext { // @public export interface CoreSetup { + // (undocumented) + application: ApplicationSetup; // (undocumented) context: ContextSetup; // (undocumented) @@ -200,7 +243,7 @@ export interface CoreSetup { // @public export interface CoreStart { // (undocumented) - application: Pick; + application: ApplicationStart; // (undocumented) chrome: ChromeStart; // (undocumented) @@ -505,23 +548,19 @@ export type IContextHandler, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (context: Partial, ...rest: TProviderParameters) => Promise | TContext[TContextName]; -// @internal (undocumented) -export interface InternalCoreSetup extends CoreSetup { - // (undocumented) - application: ApplicationSetup; +// @public @deprecated +export interface LegacyCoreSetup extends CoreSetup { // Warning: (ae-forgotten-export) The symbol "InjectedMetadataSetup" needs to be exported by the entry point index.d.ts // - // (undocumented) + // @deprecated (undocumented) injectedMetadata: InjectedMetadataSetup; } -// @internal (undocumented) -export interface InternalCoreStart extends CoreStart { - // (undocumented) - application: ApplicationStart; +// @public @deprecated +export interface LegacyCoreStart extends CoreStart { // Warning: (ae-forgotten-export) The symbol "InjectedMetadataStart" needs to be exported by the entry point index.d.ts // - // (undocumented) + // @deprecated (undocumented) injectedMetadata: InjectedMetadataStart; } diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index 5b4ab939966577..9a4b46c657f081 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -21,46 +21,67 @@ import React from 'react'; import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { RenderingService } from './rendering_service'; +import { InternalApplicationStart } from '../application'; +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; describe('RenderingService#start', () => { - const getService = () => { + const getService = ({ legacyMode = false }: { legacyMode?: boolean } = {}) => { const rendering = new RenderingService(); + const application = { + getComponent: () =>
Hello application!
, + } as InternalApplicationStart; const chrome = chromeServiceMock.createStartContract(); - chrome.getComponent.mockReturnValue(
Hello chrome!
); + chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); + injectedMetadata.getLegacyMode.mockReturnValue(legacyMode); const targetDomElement = document.createElement('div'); - const start = rendering.start({ chrome, targetDomElement }); + const start = rendering.start({ application, chrome, injectedMetadata, targetDomElement }); return { start, targetDomElement }; }; - it('renders into provided DOM element', () => { + it('renders application service into provided DOM element', () => { const { targetDomElement } = getService(); - expect(targetDomElement).toMatchInlineSnapshot(` -
-
-
- Hello chrome! -
-
-
-
-`); + expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` +
+
+ Hello application! +
+
+ `); }); - it('returns a div for the legacy service to render into', () => { - const { - start: { legacyTargetDomElement }, - targetDomElement, - } = getService(); - legacyTargetDomElement.innerHTML = 'Hello legacy!'; - expect(targetDomElement.querySelector('#legacy')).toMatchInlineSnapshot(` - - Hello legacy! - -`); + it('contains wrapper divs', () => { + const { targetDomElement } = getService(); + expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); + expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); + }); + + describe('legacyMode', () => { + it('renders into provided DOM element', () => { + const { targetDomElement } = getService({ legacyMode: true }); + expect(targetDomElement).toMatchInlineSnapshot(` +
+
+
+ Hello chrome! +
+
+
+
+ `); + }); + + it('returns a div for the legacy service to render into', () => { + const { + start: { legacyTargetDomElement }, + targetDomElement, + } = getService({ legacyMode: true }); + expect(targetDomElement.contains(legacyTargetDomElement!)).toBe(true); + }); }); }); diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index cbb931bf59ef90..2e066feca8bf35 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -22,9 +22,13 @@ import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { InternalChromeStart } from '../chrome'; +import { InternalApplicationStart } from '../application'; +import { InjectedMetadataStart } from '../injected_metadata'; interface StartDeps { + application: InternalApplicationStart; chrome: InternalChromeStart; + injectedMetadata: InjectedMetadataStart; targetDomElement: HTMLDivElement; } @@ -39,28 +43,40 @@ interface StartDeps { * @internal */ export class RenderingService { - start({ chrome, targetDomElement }: StartDeps) { - const chromeUi = chrome.getComponent(); - const legacyRef = React.createRef(); + start({ application, chrome, injectedMetadata, targetDomElement }: StartDeps): RenderingStart { + const chromeUi = chrome.getHeaderComponent(); + const appUi = application.getComponent(); + + const legacyMode = injectedMetadata.getLegacyMode(); + const legacyRef = legacyMode ? React.createRef() : null; ReactDOM.render(
{chromeUi} -
+ {!legacyMode && ( +
+
+
{appUi}
+
+
+ )} + + {legacyMode &&
}
, targetDomElement ); return { - legacyTargetDomElement: legacyRef.current!, + // When in legacy mode, return legacy div, otherwise undefined. + legacyTargetDomElement: legacyRef ? legacyRef.current! : undefined, }; } } /** @internal */ export interface RenderingStart { - legacyTargetDomElement: HTMLDivElement; + legacyTargetDomElement?: HTMLDivElement; } diff --git a/src/core/utils/context.mock.ts b/src/core/utils/context.mock.ts index d59d0066c4e6ee..4d91c11542b2f8 100644 --- a/src/core/utils/context.mock.ts +++ b/src/core/utils/context.mock.ts @@ -24,7 +24,9 @@ export type ContextContainerMock = jest.Mocked> const createContextMock = () => { const contextMock: ContextContainerMock = { registerContext: jest.fn(), - createHandler: jest.fn(), + createHandler: jest.fn((id, handler) => (...args: any[]) => + Promise.resolve(handler({}, ...args)) + ), }; contextMock.createHandler.mockImplementation((pluginId, handler) => (...args) => handler({}, ...args) diff --git a/src/core/utils/pick.ts b/src/core/utils/pick.ts index d55c76a3ca77d6..77854f9af680b4 100644 --- a/src/core/utils/pick.ts +++ b/src/core/utils/pick.ts @@ -17,10 +17,7 @@ * under the License. */ -export function pick, K extends keyof T>( - obj: T, - keys: K[] -): Pick { +export function pick(obj: T, keys: K[]): Pick { return keys.reduce( (acc, key) => { if (obj.hasOwnProperty(key)) { diff --git a/src/dev/jest/config.integration.js b/src/dev/jest/config.integration.js index 8348b7594961f1..6ffa9ac3624302 100644 --- a/src/dev/jest/config.integration.js +++ b/src/dev/jest/config.integration.js @@ -24,6 +24,7 @@ export default { testMatch: [ '**/integration_tests/**/*.test.js', '**/integration_tests/**/*.test.ts', + '**/integration_tests/**/*.test.tsx', ], testPathIgnorePatterns: config.testPathIgnorePatterns.filter( (pattern) => !pattern.includes('integration_tests') diff --git a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js index 53b627d39595e4..5f2abd8c9e0832 100644 --- a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js @@ -85,6 +85,7 @@ const coreSystem = new CoreSystem({ injectedMetadata: { version: '1.2.3', buildNumber: 1234, + legacyMode: true, legacyMetadata: { nav: [], version: '1.2.3', diff --git a/src/legacy/ui/public/chrome/chrome.js b/src/legacy/ui/public/chrome/chrome.js index 8f58da9107673f..a5a0521013a6e1 100644 --- a/src/legacy/ui/public/chrome/chrome.js +++ b/src/legacy/ui/public/chrome/chrome.js @@ -95,7 +95,6 @@ const waitForBootstrap = new Promise(resolve => { document.body.setAttribute('id', `${internals.app.id}-app`); chrome.setupAngular(); - // targetDomElement.setAttribute('id', 'kibana-body'); targetDomElement.setAttribute('kbn-chrome', 'true'); targetDomElement.setAttribute('ng-class', '{ \'hidden-chrome\': !chrome.getVisible() }'); targetDomElement.className = 'app-wrapper'; diff --git a/src/legacy/ui/public/chrome/directives/kbn_chrome.js b/src/legacy/ui/public/chrome/directives/kbn_chrome.js index d81a1ceb5f288e..755cb8b42d3637 100644 --- a/src/legacy/ui/public/chrome/directives/kbn_chrome.js +++ b/src/legacy/ui/public/chrome/directives/kbn_chrome.js @@ -77,15 +77,21 @@ export function kbnChromeProvider(chrome, internals) { // Non-scope based code (e.g., React) // Banners - ReactDOM.render( - - - , - document.getElementById('globalBannerList') - ); + const bannerListContainer = document.getElementById('globalBannerList'); + // Banners not supported in New Platform yet + // https://github.com/elastic/kibana/issues/41986 + if (bannerListContainer) { + ReactDOM.render( + + + , + bannerListContainer + ); + } + return chrome; } diff --git a/src/legacy/ui/public/legacy_compat/angular_config.tsx b/src/legacy/ui/public/legacy_compat/angular_config.tsx index 1e22003b328338..28d57e9f8e8c98 100644 --- a/src/legacy/ui/public/legacy_compat/angular_config.tsx +++ b/src/legacy/ui/public/legacy_compat/angular_config.tsx @@ -33,7 +33,7 @@ import * as Rx from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { InternalCoreStart } from 'kibana/public'; +import { CoreStart, LegacyCoreStart } from 'kibana/public'; import { fatalError } from 'ui/notify'; import { capabilities } from 'ui/capabilities'; @@ -77,7 +77,7 @@ export const configureAppAngularModule = (angularModule: IModule) => { .run($setupUrlOverflowHandling(newPlatform)); }; -const getEsUrl = (newPlatform: InternalCoreStart) => { +const getEsUrl = (newPlatform: CoreStart) => { const a = document.createElement('a'); a.href = newPlatform.http.basePath.prepend('/elasticsearch'); const protocolPort = /https/.test(a.protocol) ? 443 : 80; @@ -90,7 +90,7 @@ const getEsUrl = (newPlatform: InternalCoreStart) => { }; }; -const setupCompileProvider = (newPlatform: InternalCoreStart) => ( +const setupCompileProvider = (newPlatform: LegacyCoreStart) => ( $compileProvider: ICompileProvider ) => { if (!newPlatform.injectedMetadata.getLegacyMetadata().devMode) { @@ -98,7 +98,7 @@ const setupCompileProvider = (newPlatform: InternalCoreStart) => ( } }; -const setupLocationProvider = (newPlatform: InternalCoreStart) => ( +const setupLocationProvider = (newPlatform: CoreStart) => ( $locationProvider: ILocationProvider ) => { $locationProvider.html5Mode({ @@ -110,7 +110,7 @@ const setupLocationProvider = (newPlatform: InternalCoreStart) => ( $locationProvider.hashPrefix(''); }; -export const $setupXsrfRequestInterceptor = (newPlatform: InternalCoreStart) => { +export const $setupXsrfRequestInterceptor = (newPlatform: LegacyCoreStart) => { const version = newPlatform.injectedMetadata.getLegacyMetadata().version; // Configure jQuery prefilter @@ -145,7 +145,7 @@ export const $setupXsrfRequestInterceptor = (newPlatform: InternalCoreStart) => * @param {HttpService} $http * @return {undefined} */ -const capture$httpLoadingCount = (newPlatform: InternalCoreStart) => ( +const capture$httpLoadingCount = (newPlatform: CoreStart) => ( $rootScope: IRootScopeService, $http: IHttpService ) => { @@ -166,7 +166,7 @@ const capture$httpLoadingCount = (newPlatform: InternalCoreStart) => ( * lets us integrate with the angular router so that we can automatically clear * the breadcrumbs if we switch to a Kibana app that does not use breadcrumbs correctly */ -const $setupBreadcrumbsAutoClear = (newPlatform: InternalCoreStart) => ( +const $setupBreadcrumbsAutoClear = (newPlatform: CoreStart) => ( $rootScope: IRootScopeService, $injector: any ) => { @@ -213,7 +213,7 @@ const $setupBreadcrumbsAutoClear = (newPlatform: InternalCoreStart) => ( * lets us integrate with the angular router so that we can automatically clear * the badge if we switch to a Kibana app that does not use the badge correctly */ -const $setupBadgeAutoClear = (newPlatform: InternalCoreStart) => ( +const $setupBadgeAutoClear = (newPlatform: CoreStart) => ( $rootScope: IRootScopeService, $injector: any ) => { @@ -253,7 +253,7 @@ const $setupBadgeAutoClear = (newPlatform: InternalCoreStart) => ( * the helpExtension if we switch to a Kibana app that does not set its own * helpExtension */ -const $setupHelpExtensionAutoClear = (newPlatform: InternalCoreStart) => ( +const $setupHelpExtensionAutoClear = (newPlatform: CoreStart) => ( $rootScope: IRootScopeService, $injector: any ) => { @@ -285,7 +285,7 @@ const $setupHelpExtensionAutoClear = (newPlatform: InternalCoreStart) => ( }); }; -const $setupUrlOverflowHandling = (newPlatform: InternalCoreStart) => ( +const $setupUrlOverflowHandling = (newPlatform: CoreStart) => ( $location: ILocationService, $rootScope: IRootScopeService, Private: any, diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index 5e0eb2feeb4501..4f55349e3efe26 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { InternalCoreSetup, InternalCoreStart } from '../../../../core/public'; +import { LegacyCoreSetup, LegacyCoreStart } from '../../../../core/public'; import { Plugin as DataPlugin } from '../../../../plugins/data/public'; import { Setup as InspectorSetup, @@ -34,12 +34,12 @@ export interface PluginsStart { } export const npSetup = { - core: (null as unknown) as InternalCoreSetup, + core: (null as unknown) as LegacyCoreSetup, plugins: {} as PluginsSetup, }; export const npStart = { - core: (null as unknown) as InternalCoreStart, + core: (null as unknown) as LegacyCoreStart, plugins: {} as PluginsStart, }; @@ -48,18 +48,18 @@ export const npStart = { * @internal */ export function __reset__() { - npSetup.core = (null as unknown) as InternalCoreSetup; + npSetup.core = (null as unknown) as LegacyCoreSetup; npSetup.plugins = {} as any; - npStart.core = (null as unknown) as InternalCoreStart; + npStart.core = (null as unknown) as LegacyCoreStart; npStart.plugins = {} as any; } -export function __setup__(coreSetup: InternalCoreSetup, plugins: PluginsSetup) { +export function __setup__(coreSetup: LegacyCoreSetup, plugins: PluginsSetup) { npSetup.core = coreSetup; npSetup.plugins = plugins; } -export function __start__(coreStart: InternalCoreStart, plugins: PluginsStart) { +export function __start__(coreStart: LegacyCoreStart, plugins: PluginsStart) { npStart.core = coreStart; npStart.plugins = plugins; } diff --git a/src/legacy/ui/ui_bundles/ui_bundles_controller.js b/src/legacy/ui/ui_bundles/ui_bundles_controller.js index a4521268ea121c..7041d54d8804c9 100644 --- a/src/legacy/ui/ui_bundles/ui_bundles_controller.js +++ b/src/legacy/ui/ui_bundles/ui_bundles_controller.js @@ -81,6 +81,13 @@ export class UiBundlesController { this._postLoaders = []; this._bundles = []; + // create a bundle for core-only with no modules + this.add({ + id: 'core', + modules: [], + template: appEntryTemplate + }); + // create a bundle for each uiApp for (const uiApp of uiApps) { this.add({ diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 47d13184bfd0a1..7e6609c4b5487e 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -102,9 +102,7 @@ export function uiRenderMixin(kbnServer, server, config) { async handler(request, h) { const { id } = request.params; const app = server.getUiAppById(id) || server.getHiddenUiAppById(id); - if (!app) { - throw Boom.notFound(`Unknown app: ${id}`); - } + const isCore = !app; const uiSettings = request.getUiSettingsService(); const darkMode = !authEnabled || request.auth.isAuthenticated @@ -130,7 +128,9 @@ export function uiRenderMixin(kbnServer, server, config) { ), `${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`, `${regularBundlePath}/commons.style.css`, - `${regularBundlePath}/${app.getId()}.style.css`, + ...( + !isCore ? [`${regularBundlePath}/${app.getId()}.style.css`] : [] + ), ...kbnServer.uiExports.styleSheetPaths .filter(path => ( path.theme === '*' || path.theme === (darkMode ? 'dark' : 'light') @@ -145,7 +145,7 @@ export function uiRenderMixin(kbnServer, server, config) { const bootstrap = new AppBootstrap({ templateData: { - appId: app.getId(), + appId: isCore ? 'core' : app.getId(), regularBundlePath, dllBundlePath, styleSheetPaths, @@ -164,12 +164,11 @@ export function uiRenderMixin(kbnServer, server, config) { }); server.route({ - path: '/app/{id}', + path: '/app/{id}/{any*}', method: 'GET', async handler(req, h) { const id = req.params.id; const app = server.getUiAppById(id); - if (!app) throw Boom.notFound('Unknown app ' + id); try { if (kbnServer.status.isGreen()) { @@ -183,9 +182,15 @@ export function uiRenderMixin(kbnServer, server, config) { } }); - async function getLegacyKibanaPayload({ app, translations, request, includeUserProvidedConfig }) { + async function getUiSettings({ request, includeUserProvidedConfig }) { const uiSettings = request.getUiSettingsService(); + return props({ + defaults: uiSettings.getDefaults(), + user: includeUserProvidedConfig && uiSettings.getUserProvided() + }); + } + async function getLegacyKibanaPayload({ app, translations, request, includeUserProvidedConfig }) { return { app, translations, @@ -198,16 +203,15 @@ export function uiRenderMixin(kbnServer, server, config) { basePath: request.getBasePath(), serverName: config.get('server.name'), devMode: config.get('env.dev'), - uiSettings: await props({ - defaults: uiSettings.getDefaults(), - user: includeUserProvidedConfig && uiSettings.getUserProvided() - }) + uiSettings: await getUiSettings({ request, includeUserProvidedConfig }), }; } async function renderApp({ app, h, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) { const request = h.request; const basePath = request.getBasePath(); + const uiSettings = await getUiSettings({ request, includeUserProvidedConfig }); + app = app || { getId: () => 'core' }; const legacyMetadata = await getLegacyKibanaPayload({ app, @@ -228,13 +232,14 @@ export function uiRenderMixin(kbnServer, server, config) { bootstrapScriptUrl: `${basePath}/bundles/app/${app.getId()}/bootstrap.js`, i18n: (id, options) => i18n.translate(id, options), locale: i18n.getLocale(), - darkMode: get(legacyMetadata.uiSettings.user, ['theme:darkMode', 'userValue'], false), + darkMode: get(uiSettings.user, ['theme:darkMode', 'userValue'], false), injectedMetadata: { version: kbnServer.version, buildNumber: config.get('pkg.buildNum'), branch: config.get('pkg.branch'), basePath, + legacyMode: app.getId() !== 'core', i18n: { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, @@ -245,7 +250,7 @@ export function uiRenderMixin(kbnServer, server, config) { request, mergeVariables( injectedVarsOverrides, - await server.getInjectedUiAppVars(app.getId()), + app ? await server.getInjectedUiAppVars(app.getId()) : {}, defaultInjectedVars, ), ), diff --git a/src/legacy/ui/ui_render/views/ui_app.pug b/src/legacy/ui/ui_render/views/ui_app.pug index 5bbcc51e7745c1..95b321e09b5002 100644 --- a/src/legacy/ui/ui_render/views/ui_app.pug +++ b/src/legacy/ui/ui_render/views/ui_app.pug @@ -114,7 +114,7 @@ block content } } - .kibanaWelcomeView(id="kbn_loading_message", style="display: none;") + .kibanaWelcomeView(id="kbn_loading_message", style="display: none;", data-test-subj="kbnLoadingMessage") .kibanaLoaderWrap .kibanaLoader .kibanaWelcomeLogoCircle diff --git a/test/functional/page_objects/common_page.js b/test/functional/page_objects/common_page.js index 651d82608961a4..5e17a4042e5579 100644 --- a/test/functional/page_objects/common_page.js +++ b/test/functional/page_objects/common_page.js @@ -151,11 +151,22 @@ export function CommonPageProvider({ getService, getPageObjects }) { navigateToApp(appName, { basePath = '', shouldLoginIfPrompted = true, shouldAcceptAlert = true, hash = '' } = {}) { const self = this; - const appConfig = config.get(['apps', appName]); - const appUrl = getUrl.noAuth(config.get('servers.kibana'), { - pathname: `${basePath}${appConfig.pathname}`, - hash: hash || appConfig.hash, - }); + + let appUrl; + if (config.has(['apps', appName])) { + // Legacy applications + const appConfig = config.get(['apps', appName]); + appUrl = getUrl.noAuth(config.get('servers.kibana'), { + pathname: `${basePath}${appConfig.pathname}`, + hash: hash || appConfig.hash, + }); + } else { + appUrl = getUrl.noAuth(config.get('servers.kibana'), { + pathname: `${basePath}/app/${appName}`, + hash + }); + } + log.debug('navigating to ' + appName + ' url: ' + appUrl); function navigateTo(url) { diff --git a/test/plugin_functional/plugins/core_plugin_a/public/application.tsx b/test/plugin_functional/plugins/core_plugin_a/public/application.tsx new file mode 100644 index 00000000000000..5d464cf0405d04 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_a/public/application.tsx @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter as Router, Route, withRouter, RouteComponentProps } from 'react-router-dom'; + +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiPageSideBar, + EuiTitle, + EuiSideNav, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const Home = () => ( + + + + +

Welcome to Foo!

+
+
+
+ + + + +

Bar home page section title

+
+
+
+ Wow what a home page this is! +
+
+); + +const PageA = () => ( + + + + +

Page A

+
+
+
+ + + + +

Page A section title

+
+
+
+ Page A's content goes here +
+
+); + +type NavProps = RouteComponentProps & { + navigateToApp: AppMountContext['core']['application']['navigateToApp']; +}; +const Nav = withRouter(({ history, navigateToApp }: NavProps) => ( + history.push('/'), + 'data-test-subj': 'fooNavHome', + }, + { + id: 'page-a', + name: 'Page A', + onClick: () => history.push('/page-a'), + 'data-test-subj': 'fooNavPageA', + }, + { + id: 'linktobar', + name: 'Open Bar / Page B', + onClick: () => navigateToApp('bar', { path: 'page-b?query=here', state: 'foo!!' }), + 'data-test-subj': 'fooNavBarPageB', + }, + ], + }, + ]} + /> +)); + +const FooApp = ({ basename, context }: { basename: string; context: AppMountContext }) => ( + + + +