diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md index d32faa55a5f86..4608767651d2a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md @@ -20,4 +20,5 @@ export interface AppMountParameters | [history](./kibana-plugin-core-public.appmountparameters.history.md) | ScopedHistory<HistoryLocationState> | A scoped history instance for your application. Should be used to wire up your applications Router. | | [onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md) | (handler: AppLeaveHandler) => void | A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. | | [setHeaderActionMenu](./kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md) | (menuMount: MountPoint \| undefined) => void | A function that can be used to set the mount point used to populate the application action container in the chrome header.Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. Calling the handler with undefined will unmount the current mount point. Calling the handler after the application has been unmounted will have no effect. | +| [theme$](./kibana-plugin-core-public.appmountparameters.theme_.md) | Observable<CoreTheme> | An observable emitting [Core's theme](./kibana-plugin-core-public.coretheme.md). Should be used when mounting the application to include theme information. | diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.theme_.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.theme_.md new file mode 100644 index 0000000000000..dd105e937a3a8 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.theme_.md @@ -0,0 +1,33 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) > [theme$](./kibana-plugin-core-public.appmountparameters.theme_.md) + +## AppMountParameters.theme$ property + +An observable emitting [Core's theme](./kibana-plugin-core-public.coretheme.md). Should be used when mounting the application to include theme information. + +Signature: + +```typescript +theme$: Observable; +``` + +## Example + +When mounting a react application: + +```ts +// application.tsx +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { AppMountParameters } from 'src/core/public'; +import { wrapWithTheme } from 'src/plugins/kibana_react'; +import { MyApp } from './app'; + +export renderApp = ({ element, theme$ }: AppMountParameters) => { + ReactDOM.render(wrapWithTheme(, theme$), element); + return () => ReactDOM.unmountComponentAtNode(element); +} +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index 18af0c7ea5855..9488b8a26b867 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -22,5 +22,6 @@ export interface CoreSetup + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [theme](./kibana-plugin-core-public.coresetup.theme.md) + +## CoreSetup.theme property + +[ThemeServiceSetup](./kibana-plugin-core-public.themeservicesetup.md) + +Signature: + +```typescript +theme: ThemeServiceSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.corestart.md b/docs/development/core/public/kibana-plugin-core-public.corestart.md index e0f6a68782410..ae67696e12501 100644 --- a/docs/development/core/public/kibana-plugin-core-public.corestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.corestart.md @@ -27,5 +27,6 @@ export interface CoreStart | [notifications](./kibana-plugin-core-public.corestart.notifications.md) | NotificationsStart | [NotificationsStart](./kibana-plugin-core-public.notificationsstart.md) | | [overlays](./kibana-plugin-core-public.corestart.overlays.md) | OverlayStart | [OverlayStart](./kibana-plugin-core-public.overlaystart.md) | | [savedObjects](./kibana-plugin-core-public.corestart.savedobjects.md) | SavedObjectsStart | [SavedObjectsStart](./kibana-plugin-core-public.savedobjectsstart.md) | +| [theme](./kibana-plugin-core-public.corestart.theme.md) | ThemeServiceStart | [ThemeServiceStart](./kibana-plugin-core-public.themeservicestart.md) | | [uiSettings](./kibana-plugin-core-public.corestart.uisettings.md) | IUiSettingsClient | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | diff --git a/docs/development/core/public/kibana-plugin-core-public.corestart.theme.md b/docs/development/core/public/kibana-plugin-core-public.corestart.theme.md new file mode 100644 index 0000000000000..306ab211798fe --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.corestart.theme.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreStart](./kibana-plugin-core-public.corestart.md) > [theme](./kibana-plugin-core-public.corestart.theme.md) + +## CoreStart.theme property + +[ThemeServiceStart](./kibana-plugin-core-public.themeservicestart.md) + +Signature: + +```typescript +theme: ThemeServiceStart; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.coretheme.darkmode.md b/docs/development/core/public/kibana-plugin-core-public.coretheme.darkmode.md new file mode 100644 index 0000000000000..d62f9486e66ee --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.coretheme.darkmode.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreTheme](./kibana-plugin-core-public.coretheme.md) > [darkMode](./kibana-plugin-core-public.coretheme.darkmode.md) + +## CoreTheme.darkMode property + +is dark mode enabled or not + +Signature: + +```typescript +readonly darkMode: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.coretheme.md b/docs/development/core/public/kibana-plugin-core-public.coretheme.md new file mode 100644 index 0000000000000..552113b8d2c47 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.coretheme.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreTheme](./kibana-plugin-core-public.coretheme.md) + +## CoreTheme interface + +Contains all the required information to apply Kibana's theme at the various levels it can be used. + +Signature: + +```typescript +export interface CoreTheme +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [darkMode](./kibana-plugin-core-public.coretheme.darkmode.md) | boolean | is dark mode enabled or not | + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index dee77e8994155..b51f5ed833fd3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -58,6 +58,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) | | | [CoreSetup](./kibana-plugin-core-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | +| [CoreTheme](./kibana-plugin-core-public.coretheme.md) | Contains all the required information to apply Kibana's theme at the various levels it can be used. | | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | DeprecationsService provides methods to fetch domain deprecation details from the Kibana server. | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | @@ -131,6 +132,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) | | | [SavedObjectsStart](./kibana-plugin-core-public.savedobjectsstart.md) | | | [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) | | +| [ThemeServiceSetup](./kibana-plugin-core-public.themeservicesetup.md) | | +| [ThemeServiceStart](./kibana-plugin-core-public.themeservicestart.md) | | | [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | | [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) | UiSettings parameters defined by the plugins. | | [UiSettingsState](./kibana-plugin-core-public.uisettingsstate.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.themeservicesetup.md b/docs/development/core/public/kibana-plugin-core-public.themeservicesetup.md new file mode 100644 index 0000000000000..f372ed9b22efb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.themeservicesetup.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ThemeServiceSetup](./kibana-plugin-core-public.themeservicesetup.md) + +## ThemeServiceSetup interface + + +Signature: + +```typescript +export interface ThemeServiceSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [theme$](./kibana-plugin-core-public.themeservicesetup.theme_.md) | Observable<CoreTheme> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.themeservicesetup.theme_.md b/docs/development/core/public/kibana-plugin-core-public.themeservicesetup.theme_.md new file mode 100644 index 0000000000000..e043d32a69629 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.themeservicesetup.theme_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ThemeServiceSetup](./kibana-plugin-core-public.themeservicesetup.md) > [theme$](./kibana-plugin-core-public.themeservicesetup.theme_.md) + +## ThemeServiceSetup.theme$ property + +Signature: + +```typescript +theme$: Observable; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.themeservicestart.md b/docs/development/core/public/kibana-plugin-core-public.themeservicestart.md new file mode 100644 index 0000000000000..85122b31e4f29 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.themeservicestart.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ThemeServiceStart](./kibana-plugin-core-public.themeservicestart.md) + +## ThemeServiceStart interface + + +Signature: + +```typescript +export interface ThemeServiceStart +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [theme$](./kibana-plugin-core-public.themeservicestart.theme_.md) | Observable<CoreTheme> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.themeservicestart.theme_.md b/docs/development/core/public/kibana-plugin-core-public.themeservicestart.theme_.md new file mode 100644 index 0000000000000..416f1b1de6ede --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.themeservicestart.theme_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ThemeServiceStart](./kibana-plugin-core-public.themeservicestart.md) > [theme$](./kibana-plugin-core-public.themeservicestart.theme_.md) + +## ThemeServiceStart.theme$ property + +Signature: + +```typescript +theme$: Observable; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.collect.md b/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.collect.md index 36cb2d2d20944..ff9c57787f71d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.collect.md +++ b/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.collect.md @@ -6,6 +6,8 @@ Collect gathers event loop delays metrics from nodejs perf\_hooks.monitorEventLoopDelay the histogram calculations start from the last time `reset` was called or this EventLoopDelaysMonitor instance was created. +Returns metrics in milliseconds. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.md b/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.md index 21bbd8b48840c..e5d35547d3bdb 100644 --- a/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.md +++ b/docs/development/core/server/kibana-plugin-core-server.eventloopdelaysmonitor.md @@ -20,7 +20,7 @@ export declare class EventLoopDelaysMonitor | Method | Modifiers | Description | | --- | --- | --- | -| [collect()](./kibana-plugin-core-server.eventloopdelaysmonitor.collect.md) | | Collect gathers event loop delays metrics from nodejs perf\_hooks.monitorEventLoopDelay the histogram calculations start from the last time reset was called or this EventLoopDelaysMonitor instance was created. | +| [collect()](./kibana-plugin-core-server.eventloopdelaysmonitor.collect.md) | | Collect gathers event loop delays metrics from nodejs perf\_hooks.monitorEventLoopDelay the histogram calculations start from the last time reset was called or this EventLoopDelaysMonitor instance was created.Returns metrics in milliseconds. | | [reset()](./kibana-plugin-core-server.eventloopdelaysmonitor.reset.md) | | Resets the collected histogram data. | | [stop()](./kibana-plugin-core-server.eventloopdelaysmonitor.stop.md) | | Disables updating the interval timer for collecting new data points. | diff --git a/docs/development/core/server/kibana-plugin-core-server.intervalhistogram.md b/docs/development/core/server/kibana-plugin-core-server.intervalhistogram.md index 39f2d570cd259..56a87a1577e98 100644 --- a/docs/development/core/server/kibana-plugin-core-server.intervalhistogram.md +++ b/docs/development/core/server/kibana-plugin-core-server.intervalhistogram.md @@ -4,7 +4,7 @@ ## IntervalHistogram interface -an IntervalHistogram object that samples and reports the event loop delay over time. The delays will be reported in nanoseconds. +an IntervalHistogram object that samples and reports the event loop delay over time. The delays will be reported in milliseconds. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 2eed71cc6ecea..17c2ab736044e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -101,7 +101,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IKibanaResponse](./kibana-plugin-core-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | -| [IntervalHistogram](./kibana-plugin-core-server.intervalhistogram.md) | an IntervalHistogram object that samples and reports the event loop delay over time. The delays will be reported in nanoseconds. | +| [IntervalHistogram](./kibana-plugin-core-server.intervalhistogram.md) | an IntervalHistogram object that samples and reports the event loop delay over time. The delays will be reported in milliseconds. | | [IRenderOptions](./kibana-plugin-core-server.irenderoptions.md) | | | [IRouter](./kibana-plugin-core-server.irouter.md) | Registers route handlers for specified resource path and method. See [RouteConfig](./kibana-plugin-core-server.routeconfig.md) and [RequestHandler](./kibana-plugin-core-server.requesthandler.md) for more information about arguments to route registrations. | | [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) | | diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index a6c9eb27e338a..17de9503bd533 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -83,5 +83,11 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` setAppActionMenu={[Function]} setAppLeaveHandler={[Function]} setIsMounting={[Function]} + theme$={ + Observable { + "_isScalar": false, + "_subscribe": [Function], + } + } /> `; diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index 3799624800c99..6319d7caebf36 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -11,12 +11,15 @@ import { BehaviorSubject, Subject } from 'rxjs'; import type { MountPoint } from '../types'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; +import { themeServiceMock } from '../theme/theme_service.mock'; +import { scopedHistoryMock } from './scoped_history.mock'; import { ApplicationSetup, InternalApplicationStart, ApplicationStart, InternalApplicationSetup, PublicAppInfo, + AppMountParameters, } from './types'; import { ApplicationServiceContract } from './test_types'; @@ -81,6 +84,19 @@ const createInternalStartContractMock = (): jest.Mocked) => { + const mock: AppMountParameters = { + element: document.createElement('div'), + history: scopedHistoryMock.create(), + appBasePath: '/app', + onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), + theme$: themeServiceMock.createTheme$(), + ...parts, + }; + return mock; +}; + const createMock = (): jest.Mocked => ({ setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), start: jest.fn().mockReturnValue(createInternalStartContractMock()), @@ -93,4 +109,5 @@ export const applicationServiceMock = { createStartContract: createStartContractMock, createInternalSetupContract: createInternalSetupContractMock, createInternalStartContract: createInternalStartContractMock, + createAppMountParameters: createAppMountParametersMock, }; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index f348936d26795..ccb0b220e0243 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -19,6 +19,7 @@ import { mount, shallow } from 'enzyme'; import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; +import { themeServiceMock } from '../theme/theme_service.mock'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; import { App, AppDeepLink, AppNavLinkStatus, AppStatus, AppUpdater, PublicAppInfo } from './types'; @@ -44,7 +45,11 @@ describe('#setup()', () => { http, redirectTo: jest.fn(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), + }; service = new ApplicationService(); }); @@ -454,7 +459,11 @@ describe('#start()', () => { http, redirectTo: jest.fn(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), + }; service = new ApplicationService(); }); @@ -1124,7 +1133,11 @@ describe('#stop()', () => { setupDeps = { http, }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), + }; service = new ApplicationService(); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 3ba0d78cf15fd..9f5470a2d248e 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -15,6 +15,7 @@ import { MountPoint } from '../types'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; import { PluginOpaqueId } from '../plugins'; +import type { ThemeServiceStart } from '../theme'; import { AppRouter } from './ui'; import { Capabilities, CapabilitiesService } from './capabilities'; import { @@ -44,6 +45,7 @@ interface SetupDeps { interface StartDeps { http: HttpStart; + theme: ThemeServiceStart; overlays: OverlayStart; } @@ -191,7 +193,7 @@ export class ApplicationService { }; } - public async start({ http, overlays }: StartDeps): Promise { + public async start({ http, overlays, theme }: StartDeps): Promise { if (!this.redirectTo) { throw new Error('ApplicationService#setup() must be invoked before start.'); } @@ -314,6 +316,7 @@ export class ApplicationService { return ( { http, history: history as any, }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), + }; service = new ApplicationService(); }); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 2543d22ee6d31..e627c8ee09a38 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { createMemoryHistory, History, createHashHistory } from 'history'; +import { themeServiceMock } from '../../theme/theme_service.mock'; import { AppRouter, AppNotFound } from '../ui'; import { MockedMounterMap, MockedMounterTuple } from '../test_types'; import { createRenderer, createAppMounter, getUnmounter } from './utils'; @@ -19,6 +20,7 @@ describe('AppRouter', () => { let mounters: MockedMounterMap; let globalHistory: History; let update: ReturnType; + let theme$: ReturnType; let scopedAppHistory: History; const navigate = (path: string) => { @@ -49,6 +51,7 @@ describe('AppRouter', () => { setAppLeaveHandler={noop} setAppActionMenu={noop} setIsMounting={noop} + theme$={theme$} /> ); @@ -85,10 +88,25 @@ describe('AppRouter', () => { appRoute: '/app/my-app/app6', }), ] as MockedMounterTuple[]); + theme$ = themeServiceMock.createTheme$(); globalHistory = createMemoryHistory(); update = createMountersRenderer(); }); + it('calls mount handler with the correct parameters', async () => { + const app1 = mounters.get('app1')!; + + await navigate('/app/app1'); + + expect(app1.mounter.mount).toHaveBeenCalledTimes(1); + expect(app1.mounter.mount).toHaveBeenCalledWith( + expect.objectContaining({ + appBasePath: '/app/app1', + theme$, + }) + ); + }); + it('calls mount handler and returned unmount function when navigating between apps', async () => { const app1 = mounters.get('app1')!; const app2 = mounters.get('app2')!; diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 94930f55b8b2c..9c3294086efcc 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -11,6 +11,7 @@ import { History } from 'history'; import { RecursiveReadonly } from '@kbn/utility-types'; import { MountPoint } from '../types'; +import { CoreTheme } from '../theme'; import { Capabilities } from './capabilities'; import { PluginOpaqueId } from '../plugins'; import { AppCategory } from '../../types'; @@ -520,6 +521,29 @@ export interface AppMountParameters { * ``` */ setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + + /** + * An observable emitting {@link CoreTheme | Core's theme}. + * Should be used when mounting the application to include theme information. + * + * @example + * When mounting a react application: + * ```ts + * // application.tsx + * import React from 'react'; + * import ReactDOM from 'react-dom'; + * + * import { AppMountParameters } from 'src/core/public'; + * import { wrapWithTheme } from 'src/plugins/kibana_react'; + * import { MyApp } from './app'; + * + * export renderApp = ({ element, theme$ }: AppMountParameters) => { + * ReactDOM.render(wrapWithTheme(, theme$), element); + * return () => ReactDOM.unmountComponentAtNode(element); + * } + * ``` + */ + theme$: Observable; } /** diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index 4c056e748f06e..9fc07530a0095 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -8,8 +8,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mount } from 'enzyme'; +import { mountWithIntl } from '@kbn/test/jest'; +import { themeServiceMock } from '../../theme/theme_service.mock'; import { AppContainer } from './app_container'; import { Mounter, AppMountParameters, AppStatus } from '../types'; import { createMemoryHistory } from 'history'; @@ -20,6 +21,7 @@ describe('AppContainer', () => { const setAppLeaveHandler = jest.fn(); const setAppActionMenu = jest.fn(); const setIsMounting = jest.fn(); + const theme$ = themeServiceMock.createTheme$(); beforeEach(() => { setAppLeaveHandler.mockClear(); @@ -59,11 +61,59 @@ describe('AppContainer', () => { }, }); + it('should call the `mount` function with the correct parameters', async () => { + const mounter: Mounter = { + appBasePath: '/base-path', + appRoute: '/some-route', + unmountBeforeMounting: false, + exactRoute: false, + deepLinkPaths: {}, + mount: jest.fn().mockImplementation(({ element }) => { + const container = document.createElement('div'); + container.innerHTML = 'some-content'; + element.appendChild(container); + return () => container.remove(); + }), + }; + + const wrapper = mountWithIntl( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + theme$={theme$} + /> + ); + + await act(async () => { + await flushPromises(); + wrapper.update(); + }); + + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(mounter.mount).toHaveBeenCalledWith({ + appBasePath: '/base-path', + history: expect.any(ScopedHistory), + element: expect.any(HTMLElement), + theme$, + onAppLeave: expect.any(Function), + setHeaderActionMenu: expect.any(Function), + }); + }); + it('should hide the "not found" page before mounting the route', async () => { const [waitPromise, resolvePromise] = createResolver(); const mounter = createMounter(waitPromise); - const wrapper = mount( + const wrapper = mountWithIntl( { // Create a history using the appPath as the current location new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) } + theme$={theme$} /> ); @@ -104,7 +155,7 @@ describe('AppContainer', () => { const [waitPromise, resolvePromise] = createResolver(); const mounter = createMounter(waitPromise); - const wrapper = mount( + const wrapper = mountWithIntl( { // Create a history using the appPath as the current location new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) } + theme$={theme$} /> ); @@ -147,7 +199,7 @@ describe('AppContainer', () => { }, }; - const wrapper = mount( + const wrapper = mountWithIntl( { // Create a history using the appPath as the current location new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) } + theme$={theme$} /> ); diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 0312c707e1049..764a8e0485104 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -7,6 +7,7 @@ */ import './app_container.scss'; +import { Observable } from 'rxjs'; import React, { Fragment, FunctionComponent, @@ -19,6 +20,7 @@ import { EuiLoadingElastic } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { MountPoint } from '../../types'; +import { CoreTheme } from '../../theme'; import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; import { ScopedHistory } from '../scoped_history'; @@ -29,6 +31,7 @@ interface Props { appPath: string; appId: string; mounter?: Mounter; + theme$: Observable; appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; @@ -45,6 +48,7 @@ export const AppContainer: FunctionComponent = ({ createScopedHistory, appStatus, setIsMounting, + theme$, }: Props) => { const [showSpinner, setShowSpinner] = useState(true); const [appNotFound, setAppNotFound] = useState(false); @@ -77,6 +81,7 @@ export const AppContainer: FunctionComponent = ({ appBasePath: mounter.appBasePath, history: createScopedHistory(appPath), element: elementRef.current!, + theme$, onAppLeave: (handler) => setAppLeaveHandler(appId, handler), setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), })) || null; @@ -104,6 +109,7 @@ export const AppContainer: FunctionComponent = ({ setAppActionMenu, appPath, setIsMounting, + theme$, ]); return ( diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 8b74930b07462..0d398cfed12e4 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -13,6 +13,7 @@ import { Observable } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import type { MountPoint } from '../../types'; +import { CoreTheme } from '../../theme'; import { AppLeaveHandler, AppStatus, Mounter } from '../types'; import { AppContainer } from './app_container'; import { ScopedHistory } from '../scoped_history'; @@ -20,6 +21,7 @@ import { ScopedHistory } from '../scoped_history'; interface Props { mounters: Map; history: History; + theme$: Observable; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; @@ -33,6 +35,7 @@ interface Params { export const AppRouter: FunctionComponent = ({ history, mounters, + theme$, setAppLeaveHandler, setAppActionMenu, appStatuses$, @@ -57,7 +60,7 @@ export const AppRouter: FunctionComponent = ({ appPath={path} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting, theme$ }} /> )} /> @@ -79,7 +82,7 @@ export const AppRouter: FunctionComponent = ({ appId={id ?? appId} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting, theme$ }} /> ); }} diff --git a/src/core/public/core_app/errors/error_application.test.ts b/src/core/public/core_app/errors/error_application.test.ts index 769790cabe2e3..1e12c2db6f8d1 100644 --- a/src/core/public/core_app/errors/error_application.test.ts +++ b/src/core/public/core_app/errors/error_application.test.ts @@ -10,6 +10,8 @@ import { act } from 'react-dom/test-utils'; import { History, createMemoryHistory } from 'history'; import { IBasePath } from '../../http'; import { BasePath } from '../../http/base_path'; +import { ScopedHistory } from '../../application/scoped_history'; +import { applicationServiceMock } from '../../application/application_service.mock'; import { renderApp } from './error_application'; @@ -17,13 +19,21 @@ describe('renderApp', () => { let basePath: IBasePath; let element: HTMLDivElement; let history: History; - let unmount: any; + let unmount: () => void; beforeEach(() => { basePath = new BasePath(); element = document.createElement('div'); history = createMemoryHistory(); - unmount = renderApp({ element, history } as any, { basePath }); + unmount = renderApp( + applicationServiceMock.createAppMountParameters({ + element, + history: new ScopedHistory(history, '/'), + }), + { + basePath, + } + ); }); afterEach(() => unmount()); diff --git a/src/core/public/core_app/errors/error_application.tsx b/src/core/public/core_app/errors/error_application.tsx index 8b8f3b999dc11..c04f6fd5b5408 100644 --- a/src/core/public/core_app/errors/error_application.tsx +++ b/src/core/public/core_app/errors/error_application.tsx @@ -16,6 +16,7 @@ import { EuiEmptyPrompt, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/e import { UrlOverflowUi } from './url_overflow_ui'; import { IBasePath } from '../../http'; import { AppMountParameters } from '../../application'; +import { CoreThemeProvider } from '../../theme'; interface Props { title?: string; @@ -77,10 +78,12 @@ interface Deps { * Renders UI for displaying error messages. * @internal */ -export const renderApp = ({ element, history }: AppMountParameters, { basePath }: Deps) => { +export const renderApp = ({ element, history, theme$ }: AppMountParameters, { basePath }: Deps) => { ReactDOM.render( - + + + , element ); diff --git a/src/core/public/core_app/status/render_app.tsx b/src/core/public/core_app/status/render_app.tsx index 5e688238eca48..97398af6c9cf3 100644 --- a/src/core/public/core_app/status/render_app.tsx +++ b/src/core/public/core_app/status/render_app.tsx @@ -12,6 +12,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import type { AppMountParameters } from '../../application'; import type { HttpSetup } from '../../http'; import type { NotificationsSetup } from '../../notifications'; +import { CoreThemeProvider } from '../../theme'; import { StatusApp } from './status_app'; interface Deps { @@ -19,10 +20,15 @@ interface Deps { notifications: NotificationsSetup; } -export const renderApp = ({ element }: AppMountParameters, { http, notifications }: Deps) => { +export const renderApp = ( + { element, theme$ }: AppMountParameters, + { http, notifications }: Deps +) => { ReactDOM.render( - + + + , element ); diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index afb8aec31cccd..6eddf08cd2ae1 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -20,6 +20,7 @@ import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { renderingServiceMock } from './rendering/rendering_service.mock'; import { integrationsServiceMock } from './integrations/integrations_service.mock'; import { coreAppMock } from './core_app/core_app.mock'; +import { themeServiceMock } from './theme/theme_service.mock'; export const MockInjectedMetadataService = injectedMetadataServiceMock.create(); export const InjectedMetadataServiceConstructor = jest @@ -116,3 +117,9 @@ export const CoreAppConstructor = jest.fn().mockImplementation(() => MockCoreApp jest.doMock('./core_app', () => ({ CoreApp: CoreAppConstructor, })); + +export const MockThemeService = themeServiceMock.create(); +export const ThemeServiceConstructor = jest.fn().mockImplementation(() => MockThemeService); +jest.doMock('./theme', () => ({ + ThemeService: ThemeServiceConstructor, +})); diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 8ead0f50785bd..74e8782d33739 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -32,6 +32,8 @@ import { MockIntegrationsService, CoreAppConstructor, MockCoreApp, + MockThemeService, + ThemeServiceConstructor, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -77,6 +79,7 @@ describe('constructor', () => { expect(RenderingServiceConstructor).toHaveBeenCalledTimes(1); expect(IntegrationsServiceConstructor).toHaveBeenCalledTimes(1); expect(CoreAppConstructor).toHaveBeenCalledTimes(1); + expect(ThemeServiceConstructor).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -182,6 +185,11 @@ describe('#setup()', () => { await setupCore(); expect(MockCoreApp.setup).toHaveBeenCalledTimes(1); }); + + it('calls theme#setup()', async () => { + await setupCore(); + expect(MockThemeService.setup).toHaveBeenCalledTimes(1); + }); }); describe('#start()', () => { @@ -235,6 +243,7 @@ describe('#start()', () => { expect(MockNotificationsService.start).toHaveBeenCalledWith({ i18n: expect.any(Object), overlays: expect.any(Object), + theme: expect.any(Object), targetDomElement: expect.any(HTMLElement), }); }); @@ -256,6 +265,8 @@ describe('#start()', () => { application: expect.any(Object), chrome: expect.any(Object), overlays: expect.any(Object), + i18n: expect.any(Object), + theme: expect.any(Object), targetDomElement: expect.any(HTMLElement), }); }); @@ -269,6 +280,11 @@ describe('#start()', () => { await startCore(); expect(MockCoreApp.start).toHaveBeenCalledTimes(1); }); + + it('calls theme#start()', async () => { + await startCore(); + expect(MockThemeService.start).toHaveBeenCalledTimes(1); + }); }); describe('#stop()', () => { @@ -327,6 +343,14 @@ describe('#stop()', () => { expect(MockCoreApp.stop).toHaveBeenCalled(); }); + it('calls theme.stop()', () => { + const coreSystem = createCoreSystem(); + + expect(MockThemeService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(MockThemeService.stop).toHaveBeenCalled(); + }); + it('clears the rootDomElement', async () => { const rootDomElement = document.createElement('div'); const coreSystem = createCoreSystem({ diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index e5dcd8f817a0a..3d3331d54792b 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -28,6 +28,7 @@ import { RenderingService } from './rendering'; import { SavedObjectsService } from './saved_objects'; import { IntegrationsService } from './integrations'; import { DeprecationsService } from './deprecations'; +import { ThemeService } from './theme'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; @@ -83,6 +84,7 @@ export class CoreSystem { private readonly integrations: IntegrationsService; private readonly coreApp: CoreApp; private readonly deprecations: DeprecationsService; + private readonly theme: ThemeService; private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; private fatalErrorsSetup: FatalErrorsSetup | null = null; @@ -104,6 +106,7 @@ export class CoreSystem { this.stop(); }); + this.theme = new ThemeService(); this.notifications = new NotificationsService(); this.http = new HttpService(); this.savedObjects = new SavedObjectsService(); @@ -137,6 +140,7 @@ 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 theme = this.theme.setup({ injectedMetadata }); const application = this.application.setup({ http }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); @@ -147,6 +151,7 @@ export class CoreSystem { http, injectedMetadata, notifications, + theme, uiSettings, }; @@ -174,6 +179,7 @@ export class CoreSystem { const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); const fatalErrors = await this.fatalErrors.start(); + const theme = this.theme.start(); await this.integrations.start({ uiSettings }); const coreUiTargetDomElement = document.createElement('div'); @@ -184,15 +190,17 @@ export class CoreSystem { const overlays = this.overlay.start({ i18n, - targetDomElement: overlayTargetDomElement, + theme, uiSettings, + targetDomElement: overlayTargetDomElement, }); const notifications = await this.notifications.start({ i18n, overlays, + theme, targetDomElement: notificationsTargetDomElement, }); - const application = await this.application.start({ http, overlays }); + const application = await this.application.start({ http, theme, overlays }); const chrome = await this.chrome.start({ application, docLinks, @@ -209,6 +217,7 @@ export class CoreSystem { chrome, docLinks, http, + theme, savedObjects, i18n, injectedMetadata, @@ -231,7 +240,9 @@ export class CoreSystem { this.rendering.start({ application, chrome, + i18n, overlays, + theme, targetDomElement: coreUiTargetDomElement, }); @@ -260,6 +271,7 @@ export class CoreSystem { this.i18n.stop(); this.application.stop(); this.deprecations.stop(); + this.theme.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 40326d9c67606..ded7db9c6f892 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -64,6 +64,7 @@ import { ApplicationSetup, Capabilities, ApplicationStart } from './application' import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; import { DeprecationsServiceStart } from './deprecations'; +import type { ThemeServiceSetup, ThemeServiceStart } from './theme'; export type { PackageInfo, @@ -185,6 +186,8 @@ export type { ErrorToastOptions, } from './notifications'; +export type { ThemeServiceSetup, ThemeServiceStart, CoreTheme } from './theme'; + export type { DeprecationsServiceStart, ResolveDeprecationResponse } from './deprecations'; export type { MountPoint, UnmountCallback, PublicUiSettingsParams } from './types'; @@ -227,6 +230,8 @@ export interface CoreSetup unknown; }; + /** {@link ThemeServiceSetup} */ + theme: ThemeServiceSetup; /** {@link StartServicesAccessor} */ getStartServices: StartServicesAccessor; } @@ -275,6 +280,8 @@ export interface CoreStart { fatalErrors: FatalErrorsStart; /** {@link DeprecationsServiceStart} */ deprecations: DeprecationsServiceStart; + /** {@link ThemeServiceStart} */ + theme: ThemeServiceStart; /** * exposed temporarily until https://github.com/elastic/kibana/issues/41990 done * use *only* to retrieve config values. There is no way to set injected values 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 d6dfec4b96d8a..dc8fe63724411 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -20,6 +20,7 @@ const createSetupContractMock = () => { getExternalUrlConfig: jest.fn(), getAnonymousStatusPage: jest.fn(), getLegacyMetadata: jest.fn(), + getTheme: jest.fn(), getPlugins: jest.fn(), getInjectedVar: jest.fn(), getInjectedVars: jest.fn(), @@ -41,6 +42,7 @@ const createSetupContractMock = () => { }, } as any); setupContract.getPlugins.mockReturnValue([]); + setupContract.getTheme.mockReturnValue({ darkMode: false, version: 'v8' }); return setupContract; }; diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 341fc6105bedf..bfda6d3f334ee 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -8,6 +8,7 @@ import { get } from 'lodash'; import { deepFreeze } from '@kbn/std'; +import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import { DiscoveredPlugin, PluginName } from '../../server'; import { EnvironmentMode, @@ -45,6 +46,10 @@ export interface InjectedMetadataParams { vars: { [key: string]: unknown; }; + theme: { + darkMode: boolean; + version: ThemeVersion; + }; env: { mode: Readonly; packageInfo: Readonly; @@ -132,6 +137,10 @@ export class InjectedMetadataService { getKibanaBranch: () => { return this.state.branch; }, + + getTheme: () => { + return this.state.theme; + }, }; } } @@ -154,6 +163,10 @@ export interface InjectedMetadataSetup { getExternalUrlConfig: () => { policy: IExternalUrlPolicy[]; }; + getTheme: () => { + darkMode: boolean; + version: ThemeVersion; + }; /** * An array of frontend plugins in topological order. */ diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 39d2dc3d5c497..f11839129be64 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -25,6 +25,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; import { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +import { themeServiceMock } from './theme/theme_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -39,6 +40,7 @@ export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.m export { scopedHistoryMock } from './application/scoped_history.mock'; export { applicationServiceMock } from './application/application_service.mock'; export { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +export { themeServiceMock } from './theme/theme_service.mock'; function createCoreSetupMock({ basePath = '', @@ -63,6 +65,7 @@ function createCoreSetupMock({ injectedMetadata: { getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar, }, + theme: themeServiceMock.createSetupContract(), }; return mock; @@ -80,6 +83,7 @@ function createCoreStartMock({ basePath = '' } = {}) { uiSettings: uiSettingsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), deprecations: deprecationsServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), injectedMetadata: { getInjectedVar: injectedMetadataServiceMock.createStartContract().getInjectedVar, }, @@ -156,6 +160,7 @@ function createAppMountParametersMock(appBasePath = '') { appBasePath, element: document.createElement('div'), history, + theme$: themeServiceMock.createTheme$(), onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), }; diff --git a/src/core/public/notifications/notifications_service.ts b/src/core/public/notifications/notifications_service.ts index a6eec50582e18..383fa2d1914cc 100644 --- a/src/core/public/notifications/notifications_service.ts +++ b/src/core/public/notifications/notifications_service.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; import { I18nStart } from '../i18n'; +import { ThemeServiceStart } from '../theme'; import { ToastsService, ToastsSetup, ToastsStart } from './toasts'; import { IUiSettingsClient } from '../ui_settings'; import { OverlayStart } from '../overlays'; @@ -21,6 +22,7 @@ interface SetupDeps { interface StartDeps { i18n: I18nStart; overlays: OverlayStart; + theme: ThemeServiceStart; targetDomElement: HTMLElement; } @@ -49,13 +51,23 @@ export class NotificationsService { return notificationSetup; } - public start({ i18n: i18nDep, overlays, targetDomElement }: StartDeps): NotificationsStart { + public start({ + i18n: i18nDep, + overlays, + theme, + targetDomElement, + }: StartDeps): NotificationsStart { this.targetDomElement = targetDomElement; const toastsContainer = document.createElement('div'); targetDomElement.appendChild(toastsContainer); return { - toasts: this.toasts.start({ i18n: i18nDep, overlays, targetDomElement: toastsContainer }), + toasts: this.toasts.start({ + i18n: i18nDep, + overlays, + theme, + targetDomElement: toastsContainer, + }), }; } diff --git a/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap b/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap index 75d456dd87f78..6e453ed8e48f5 100644 --- a/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap +++ b/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap @@ -3,7 +3,21 @@ exports[`#start() renders the GlobalToastList into the targetDomElement param 1`] = ` Array [ Array [ - + - , + ,
, diff --git a/src/core/public/notifications/toasts/toasts_service.test.tsx b/src/core/public/notifications/toasts/toasts_service.test.tsx index 8099d4bfabae3..6c7394433b9ed 100644 --- a/src/core/public/notifications/toasts/toasts_service.test.tsx +++ b/src/core/public/notifications/toasts/toasts_service.test.tsx @@ -11,6 +11,7 @@ import { mockReactDomRender, mockReactDomUnmount } from './toasts_service.test.m import { ToastsService } from './toasts_service'; import { ToastsApi } from './toasts_api'; import { overlayServiceMock } from '../../overlays/overlay_service.mock'; +import { themeServiceMock } from '../../theme/theme_service.mock'; import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock'; const mockI18n: any = { @@ -20,6 +21,7 @@ const mockI18n: any = { }; const mockOverlays = overlayServiceMock.createStartContract(); +const mockTheme = themeServiceMock.createStartContract(); describe('#setup()', () => { it('returns a ToastsApi', () => { @@ -39,7 +41,7 @@ describe('#start()', () => { expect(mockReactDomRender).not.toHaveBeenCalled(); toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }); - toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays }); + toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }); expect(mockReactDomRender.mock.calls).toMatchSnapshot(); }); @@ -51,7 +53,7 @@ describe('#start()', () => { toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }) ).toBeInstanceOf(ToastsApi); expect( - toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays }) + toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }) ).toBeInstanceOf(ToastsApi); }); }); @@ -63,7 +65,7 @@ describe('#stop()', () => { const toasts = new ToastsService(); toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }); - toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays }); + toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }); expect(mockReactDomUnmount).not.toHaveBeenCalled(); toasts.stop(); @@ -82,7 +84,7 @@ describe('#stop()', () => { const toasts = new ToastsService(); toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }); - toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays }); + toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }); toasts.stop(); expect(targetDomElement.childNodes).toHaveLength(0); }); diff --git a/src/core/public/notifications/toasts/toasts_service.tsx b/src/core/public/notifications/toasts/toasts_service.tsx index d50850841555f..1592c9abcc565 100644 --- a/src/core/public/notifications/toasts/toasts_service.tsx +++ b/src/core/public/notifications/toasts/toasts_service.tsx @@ -14,6 +14,8 @@ import { IUiSettingsClient } from '../../ui_settings'; import { GlobalToastList } from './global_toast_list'; import { ToastsApi, IToasts } from './toasts_api'; import { OverlayStart } from '../../overlays'; +import { ThemeServiceStart } from '../../theme'; +import { CoreContextProvider } from '../../utils'; interface SetupDeps { uiSettings: IUiSettingsClient; @@ -22,6 +24,7 @@ interface SetupDeps { interface StartDeps { i18n: I18nStart; overlays: OverlayStart; + theme: ThemeServiceStart; targetDomElement: HTMLElement; } @@ -46,17 +49,17 @@ export class ToastsService { return this.api!; } - public start({ i18n, overlays, targetDomElement }: StartDeps) { + public start({ i18n, overlays, theme, targetDomElement }: StartDeps) { this.api!.start({ overlays, i18n }); this.targetDomElement = targetDomElement; render( - + this.api!.remove(toastId)} toasts$={this.api!.get$()} /> - , + , targetDomElement ); diff --git a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap index fbd09f3096854..145847c39a0b5 100644 --- a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -11,7 +11,21 @@ Array [ exports[`FlyoutService openFlyout() renders a flyout to the DOM 1`] = ` Array [ Array [ - + @@ -20,7 +34,7 @@ Array [ mount={[Function]} /> - , + ,
, ], ] @@ -31,7 +45,68 @@ exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
+ + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > @@ -40,11 +115,72 @@ Array [ mount={[Function]} /> - , + ,
, ], Array [ - + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > @@ -53,7 +189,7 @@ Array [ mount={[Function]} /> - , + ,
, ], ] diff --git a/src/core/public/overlays/flyout/flyout_service.test.tsx b/src/core/public/overlays/flyout/flyout_service.test.tsx index 0da7e14949e6e..563e1e02f2f26 100644 --- a/src/core/public/overlays/flyout/flyout_service.test.tsx +++ b/src/core/public/overlays/flyout/flyout_service.test.tsx @@ -10,10 +10,12 @@ import { mockReactDomRender, mockReactDomUnmount } from '../overlay.test.mocks'; import { mount } from 'enzyme'; import { i18nServiceMock } from '../../i18n/i18n_service.mock'; +import { themeServiceMock } from '../../theme/theme_service.mock'; import { FlyoutService, OverlayFlyoutStart } from './flyout_service'; import { OverlayRef } from '../types'; const i18nMock = i18nServiceMock.createStartContract(); +const themeMock = themeServiceMock.createStartContract(); beforeEach(() => { mockReactDomRender.mockClear(); @@ -29,7 +31,11 @@ const mountText = (text: string) => (container: HTMLElement) => { const getServiceStart = () => { const service = new FlyoutService(); - return service.start({ i18n: i18nMock, targetDomElement: document.createElement('div') }); + return service.start({ + i18n: i18nMock, + theme: themeMock, + targetDomElement: document.createElement('div'), + }); }; describe('FlyoutService', () => { diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 79047738da4dd..600e1e0e97b25 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -13,9 +13,10 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; import { I18nStart } from '../../i18n'; +import { ThemeServiceStart } from '../../theme'; import { MountPoint } from '../../types'; import { OverlayRef } from '../types'; -import { MountWrapper } from '../../utils'; +import { MountWrapper, CoreContextProvider } from '../../utils'; /** * A FlyoutRef is a reference to an opened flyout panel. It offers methods to @@ -96,6 +97,7 @@ export interface OverlayFlyoutOpenOptions { interface StartDeps { i18n: I18nStart; + theme: ThemeServiceStart; targetDomElement: Element; } @@ -104,7 +106,7 @@ export class FlyoutService { private activeFlyout: FlyoutRef | null = null; private targetDomElement: Element | null = null; - public start({ i18n, targetDomElement }: StartDeps): OverlayFlyoutStart { + public start({ i18n, theme, targetDomElement }: StartDeps): OverlayFlyoutStart { this.targetDomElement = targetDomElement; return { @@ -135,11 +137,11 @@ export class FlyoutService { }; render( - + - , + , this.targetDomElement ); diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap index 9c39776fcea5c..31309a0cd3629 100644 --- a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -11,7 +11,68 @@ Array [ exports[`ModalService openConfirm() renders a mountpoint confirm message 1`] = ` Array [ Array [ - + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > - , + ,
, ], ] @@ -34,7 +95,116 @@ exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = ` exports[`ModalService openConfirm() renders a string confirm message 1`] = ` Array [ Array [ - + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > Some message - , + ,
, ], ] @@ -54,7 +224,158 @@ exports[`ModalService openConfirm() renders a string confirm message 2`] = `" + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + Some message + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + Some message + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > confirm 1 - , + ,
, ], Array [ - + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + Some message + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + Some message + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > some confirm - , + ,
, ], ] @@ -85,7 +557,158 @@ Array [ exports[`ModalService openConfirm() with a currently active modal replaces the current modal with the new confirm 1`] = ` Array [ Array [ - + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + Some message + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + Some message + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > @@ -94,11 +717,162 @@ Array [ mount={[Function]} /> - , + ,
, ], Array [ - + + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + + + , + }, + Object {}, + ], + Array [ + Object { + "children": + + Some message + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + + + , + }, + Object { + "type": "return", + "value": + + Some message + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > some confirm - , + ,
, ], ] @@ -116,7 +890,21 @@ Array [ exports[`ModalService openModal() renders a modal to the DOM 1`] = ` Array [ Array [ - + @@ -125,7 +913,7 @@ Array [ mount={[Function]} /> - , + ,
, ], ] @@ -136,7 +924,68 @@ exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
+ + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > confirm 1 - , + ,
, ], Array [ - + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > some confirm - , + ,
, ], ] @@ -167,7 +1077,68 @@ Array [ exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = ` Array [ Array [ - + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > @@ -176,11 +1147,72 @@ Array [ mount={[Function]} /> - , + ,
, ], Array [ - + + + + + , + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": + + + + , + }, + ], + }, + } + } + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } + > @@ -189,7 +1221,7 @@ Array [ mount={[Function]} /> - , + ,
, ], ] diff --git a/src/core/public/overlays/modal/modal_service.test.tsx b/src/core/public/overlays/modal/modal_service.test.tsx index e0a78082570c7..cd3c0d0525657 100644 --- a/src/core/public/overlays/modal/modal_service.test.tsx +++ b/src/core/public/overlays/modal/modal_service.test.tsx @@ -11,11 +11,13 @@ import { mockReactDomRender, mockReactDomUnmount } from '../overlay.test.mocks'; import React from 'react'; import { mount } from 'enzyme'; import { i18nServiceMock } from '../../i18n/i18n_service.mock'; +import { themeServiceMock } from '../../theme/theme_service.mock'; import { ModalService, OverlayModalStart } from './modal_service'; import { mountReactNode } from '../../utils'; import { OverlayRef } from '../types'; const i18nMock = i18nServiceMock.createStartContract(); +const themeMock = themeServiceMock.createStartContract(); beforeEach(() => { mockReactDomRender.mockClear(); @@ -24,7 +26,11 @@ beforeEach(() => { const getServiceStart = () => { const service = new ModalService(); - return service.start({ i18n: i18nMock, targetDomElement: document.createElement('div') }); + return service.start({ + i18n: i18nMock, + theme: themeMock, + targetDomElement: document.createElement('div'), + }); }; describe('ModalService', () => { diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index 7e4aee94c958e..f509146452934 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -14,9 +14,10 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; import { I18nStart } from '../../i18n'; +import { ThemeServiceStart } from '../../theme'; import { MountPoint } from '../../types'; import { OverlayRef } from '../types'; -import { MountWrapper } from '../../utils'; +import { MountWrapper, CoreContextProvider } from '../../utils'; /** * A ModalRef is a reference to an opened modal. It offers methods to @@ -84,6 +85,7 @@ export interface OverlayModalStart { * @return {@link OverlayRef} A reference to the opened modal. */ open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef; + /** * Opens a confirmation modal with the given text or mountpoint as a message. * Returns a Promise resolving to `true` if user confirmed or `false` otherwise. @@ -106,6 +108,7 @@ export interface OverlayModalOpenOptions { interface StartDeps { i18n: I18nStart; + theme: ThemeServiceStart; targetDomElement: Element; } @@ -114,7 +117,7 @@ export class ModalService { private activeModal: ModalRef | null = null; private targetDomElement: Element | null = null; - public start({ i18n, targetDomElement }: StartDeps): OverlayModalStart { + public start({ i18n, theme, targetDomElement }: StartDeps): OverlayModalStart { this.targetDomElement = targetDomElement; return { @@ -137,11 +140,11 @@ export class ModalService { this.activeModal = modal; render( - + modal.close()}> - , + , targetDomElement ); @@ -197,9 +200,9 @@ export class ModalService { }; render( - + - , + , targetDomElement ); }); diff --git a/src/core/public/overlays/overlay_service.ts b/src/core/public/overlays/overlay_service.ts index f62b49fa5efa4..8d4a5f5f58b56 100644 --- a/src/core/public/overlays/overlay_service.ts +++ b/src/core/public/overlays/overlay_service.ts @@ -8,12 +8,14 @@ import { I18nStart } from '../i18n'; import { IUiSettingsClient } from '../ui_settings'; +import { ThemeServiceStart } from '../theme'; import { OverlayBannersStart, OverlayBannersService } from './banners'; import { FlyoutService, OverlayFlyoutStart } from './flyout'; import { ModalService, OverlayModalStart } from './modal'; interface StartDeps { i18n: I18nStart; + theme: ThemeServiceStart; targetDomElement: HTMLElement; uiSettings: IUiSettingsClient; } @@ -24,16 +26,16 @@ export class OverlayService { private modalService = new ModalService(); private flyoutService = new FlyoutService(); - public start({ i18n, targetDomElement, uiSettings }: StartDeps): OverlayStart { + public start({ i18n, targetDomElement, uiSettings, theme }: StartDeps): OverlayStart { const flyoutElement = document.createElement('div'); targetDomElement.appendChild(flyoutElement); - const flyouts = this.flyoutService.start({ i18n, targetDomElement: flyoutElement }); + const flyouts = this.flyoutService.start({ i18n, theme, targetDomElement: flyoutElement }); const banners = this.bannersService.start({ i18n, uiSettings }); const modalElement = document.createElement('div'); targetDomElement.appendChild(modalElement); - const modals = this.modalService.start({ i18n, targetDomElement: modalElement }); + const modals = this.modalService.start({ i18n, theme, targetDomElement: modalElement }); return { banners, diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index f87f07d553e0c..345aea4b6cac8 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -94,6 +94,7 @@ export function createPluginSetupContext< injectedMetadata: { getInjectedVar: deps.injectedMetadata.getInjectedVar, }, + theme: deps.theme, getStartServices: () => plugin.startDependencies, }; } @@ -140,5 +141,6 @@ export function createPluginStartContext< }, fatalErrors: deps.fatalErrors, deprecations: deps.deprecations, + theme: deps.theme, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 6bed958009419..c4e3b7990ba32 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -35,6 +35,7 @@ import { CoreSetup, CoreStart, PluginInitializerContext } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { deprecationsServiceMock } from '../deprecations/deprecations_service.mock'; +import { themeServiceMock } from '../theme/theme_service.mock'; export let mockPluginInitializers: Map; @@ -88,6 +89,7 @@ describe('PluginsService', () => { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), + theme: themeServiceMock.createSetupContract(), }; mockSetupContext = { ...mockSetupDeps, @@ -108,6 +110,7 @@ describe('PluginsService', () => { savedObjects: savedObjectsServiceMock.createStartContract(), fatalErrors: fatalErrorsServiceMock.createStartContract(), deprecations: deprecationsServiceMock.createStartContract(), + theme: themeServiceMock.createStartContract(), }; mockStartContext = { ...mockStartDeps, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 593a02a16c15a..d1a164a2ab13d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -41,6 +41,7 @@ import { RecursiveReadonly } from '@kbn/utility-types'; import { Request as Request_2 } from '@hapi/hapi'; import * as Rx from 'rxjs'; import { SchemaTypeError } from '@kbn/config-schema'; +import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import type { TransportRequestOptions } from '@elastic/elasticsearch'; import type { TransportRequestParams } from '@elastic/elasticsearch'; import type { TransportResult } from '@elastic/elasticsearch'; @@ -170,6 +171,7 @@ export interface AppMountParameters { // @deprecated onAppLeave: (handler: AppLeaveHandler) => void; setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + theme$: Observable; } // @public @@ -407,6 +409,8 @@ export interface CoreSetup; @@ -1668,6 +1679,18 @@ export class SimpleSavedObject { // @public export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart, TStart]>; +// @public (undocumented) +export interface ThemeServiceSetup { + // (undocumented) + theme$: Observable; +} + +// @public (undocumented) +export interface ThemeServiceStart { + // (undocumented) + theme$: Observable; +} + // Warning: (ae-missing-release-tag) "Toast" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1764,6 +1787,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:168:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:173:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index bdca628b295c6..7736b146543b3 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -13,12 +13,16 @@ import { RenderingService } from './rendering_service'; import { applicationServiceMock } from '../application/application_service.mock'; import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; +import { themeServiceMock } from '../theme/theme_service.mock'; +import { i18nServiceMock } from '../i18n/i18n_service.mock'; import { BehaviorSubject } from 'rxjs'; describe('RenderingService#start', () => { let application: ReturnType; let chrome: ReturnType; let overlays: ReturnType; + let i18n: ReturnType; + let theme: ReturnType; let targetDomElement: HTMLDivElement; let rendering: RenderingService; @@ -32,6 +36,10 @@ describe('RenderingService#start', () => { overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); + theme = themeServiceMock.createStartContract(); + + i18n = i18nServiceMock.createStartContract(); + targetDomElement = document.createElement('div'); rendering = new RenderingService(); @@ -42,6 +50,8 @@ describe('RenderingService#start', () => { application, chrome, overlays, + i18n, + theme, targetDomElement, }); }; diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index d3f91851370d5..7c84146d1aa86 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -8,12 +8,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { I18nProvider } from '@kbn/i18n/react'; import { pairwise, startWith } from 'rxjs/operators'; -import { InternalChromeStart } from '../chrome'; -import { InternalApplicationStart } from '../application'; -import { OverlayStart } from '../overlays'; +import type { InternalChromeStart } from '../chrome'; +import type { InternalApplicationStart } from '../application'; +import type { OverlayStart } from '../overlays'; +import type { ThemeServiceStart } from '../theme'; +import type { I18nStart } from '../i18n'; +import { CoreContextProvider } from '../utils'; import { AppWrapper } from './app_containers'; interface StartDeps { @@ -21,6 +23,8 @@ interface StartDeps { chrome: InternalChromeStart; overlays: OverlayStart; targetDomElement: HTMLDivElement; + theme: ThemeServiceStart; + i18n: I18nStart; } /** @@ -32,7 +36,7 @@ interface StartDeps { * @internal */ export class RenderingService { - start({ application, chrome, overlays, targetDomElement }: StartDeps) { + start({ application, chrome, overlays, theme, i18n, targetDomElement }: StartDeps) { const chromeHeader = chrome.getHeaderComponent(); const appComponent = application.getComponent(); const bannerComponent = overlays.banners.getComponent(); @@ -47,7 +51,7 @@ export class RenderingService { }); ReactDOM.render( - + <> {/* Fixed headers */} {chromeHeader} @@ -64,7 +68,7 @@ export class RenderingService { {appComponent} - , + , targetDomElement ); } diff --git a/src/core/public/theme/convert_core_theme.test.ts b/src/core/public/theme/convert_core_theme.test.ts new file mode 100644 index 0000000000000..4e8473c0c45d5 --- /dev/null +++ b/src/core/public/theme/convert_core_theme.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { convertCoreTheme } from './convert_core_theme'; + +describe('convertCoreTheme', () => { + it('returns the correct `colorMode` when `darkMode` is enabled', () => { + expect(convertCoreTheme({ darkMode: true }).colorMode).toEqual('DARK'); + }); + + it('returns the correct `colorMode` when `darkMode` is disabled', () => { + expect(convertCoreTheme({ darkMode: false }).colorMode).toEqual('LIGHT'); + }); +}); diff --git a/src/core/public/theme/convert_core_theme.ts b/src/core/public/theme/convert_core_theme.ts new file mode 100644 index 0000000000000..78acef40764b6 --- /dev/null +++ b/src/core/public/theme/convert_core_theme.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { EuiThemeSystem, EuiThemeColorMode } from '@elastic/eui'; +import type { CoreTheme } from './types'; + +/** @internal */ +export interface EuiTheme { + colorMode: EuiThemeColorMode; + euiThemeSystem?: EuiThemeSystem; +} + +/** @internal */ +export const convertCoreTheme = (coreTheme: CoreTheme): EuiTheme => { + const { darkMode } = coreTheme; + return { + colorMode: darkMode ? 'DARK' : 'LIGHT', + }; +}; diff --git a/src/core/public/theme/core_theme_provider.test.tsx b/src/core/public/theme/core_theme_provider.test.tsx new file mode 100644 index 0000000000000..baa354f10f428 --- /dev/null +++ b/src/core/public/theme/core_theme_provider.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; +import type { ReactWrapper } from 'enzyme'; +import { of, BehaviorSubject } from 'rxjs'; +import { useEuiTheme } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { CoreThemeProvider } from './core_theme_provider'; +import type { CoreTheme } from './types'; + +describe('CoreThemeProvider', () => { + let euiTheme: ReturnType | undefined; + + beforeEach(() => { + euiTheme = undefined; + }); + + const flushPromises = async () => { + await new Promise(async (resolve, reject) => { + try { + setImmediate(() => resolve()); + } catch (error) { + reject(error); + } + }); + }; + + const InnerComponent: FC = () => { + const theme = useEuiTheme(); + useEffect(() => { + euiTheme = theme; + }, [theme]); + return
foo
; + }; + + const refresh = async (wrapper: ReactWrapper) => { + await act(async () => { + await flushPromises(); + wrapper.update(); + }); + }; + + it('exposes the EUI theme provider', async () => { + const coreTheme: CoreTheme = { darkMode: true }; + + const wrapper = mountWithIntl( + + + + ); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('DARK'); + }); + + it('propagates changes of the coreTheme observable', async () => { + const coreTheme$ = new BehaviorSubject({ darkMode: true }); + + const wrapper = mountWithIntl( + + + + ); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('DARK'); + + await act(async () => { + coreTheme$.next({ darkMode: false }); + }); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('LIGHT'); + }); +}); diff --git a/src/core/public/theme/core_theme_provider.tsx b/src/core/public/theme/core_theme_provider.tsx new file mode 100644 index 0000000000000..9f40cbd5393b8 --- /dev/null +++ b/src/core/public/theme/core_theme_provider.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useMemo } from 'react'; +import { Observable } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiThemeProvider } from '@elastic/eui'; +import { CoreTheme } from './types'; +import { convertCoreTheme } from './convert_core_theme'; + +const defaultTheme: CoreTheme = { + darkMode: false, +}; + +interface CoreThemeProviderProps { + theme$: Observable; +} + +/** + * Wrapper around `EuiThemeProvider` converting (and exposing) core's theme to EUI theme. + * @internal Only meant to be used within core for internal usages of EUI/React + */ +export const CoreThemeProvider: FC = ({ theme$, children }) => { + const coreTheme = useObservable(theme$, defaultTheme); + const euiTheme = useMemo(() => convertCoreTheme(coreTheme), [coreTheme]); + return ( + + {children} + + ); +}; diff --git a/src/core/public/theme/index.ts b/src/core/public/theme/index.ts new file mode 100644 index 0000000000000..1e2d07c8152cc --- /dev/null +++ b/src/core/public/theme/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ThemeService } from './theme_service'; +export type { CoreTheme, ThemeServiceSetup, ThemeServiceStart } from './types'; +export { CoreThemeProvider } from './core_theme_provider'; diff --git a/src/core/public/theme/theme_service.mock.ts b/src/core/public/theme/theme_service.mock.ts new file mode 100644 index 0000000000000..86abcabf126ba --- /dev/null +++ b/src/core/public/theme/theme_service.mock.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { of } from 'rxjs'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { ThemeServiceSetup, ThemeServiceStart, CoreTheme } from './types'; +import type { ThemeService } from './theme_service'; + +const mockTheme: CoreTheme = { + darkMode: false, +}; + +const createThemeMock = (): CoreTheme => { + return { ...mockTheme }; +}; + +const createTheme$Mock = () => { + return of(createThemeMock()); +}; + +const createThemeSetupMock = () => { + const setupMock: jest.Mocked = { + theme$: createTheme$Mock(), + }; + return setupMock; +}; + +const createThemeStartMock = () => { + const startMock: jest.Mocked = { + theme$: createTheme$Mock(), + }; + return startMock; +}; + +type ThemeServiceContract = PublicMethodsOf; + +const createServiceMock = () => { + const mocked: jest.Mocked = { + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + }; + + mocked.setup.mockReturnValue(createThemeSetupMock()); + mocked.start.mockReturnValue(createThemeStartMock()); + + return mocked; +}; + +export const themeServiceMock = { + create: createServiceMock, + createSetupContract: createThemeSetupMock, + createStartContract: createThemeStartMock, + createTheme: createThemeMock, + createTheme$: createTheme$Mock, +}; diff --git a/src/core/public/theme/theme_service.test.ts b/src/core/public/theme/theme_service.test.ts new file mode 100644 index 0000000000000..d38ef98735a3d --- /dev/null +++ b/src/core/public/theme/theme_service.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { take } from 'rxjs/operators'; +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; +import { ThemeService } from './theme_service'; + +describe('ThemeService', () => { + let themeService: ThemeService; + let injectedMetadata: ReturnType; + + beforeEach(() => { + themeService = new ThemeService(); + injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + }); + + describe('#setup', () => { + it('exposes a `theme$` observable with the values provided by the injected metadata', async () => { + injectedMetadata.getTheme.mockReturnValue({ version: 'v8', darkMode: true }); + const { theme$ } = themeService.setup({ injectedMetadata }); + const theme = await theme$.pipe(take(1)).toPromise(); + expect(theme).toEqual({ + darkMode: true, + }); + }); + }); + + describe('#start', () => { + it('throws if called before `#setup`', () => { + expect(() => { + themeService.start(); + }).toThrowErrorMatchingInlineSnapshot(`"setup must be called before start"`); + }); + + it('exposes a `theme$` observable with the values provided by the injected metadata', async () => { + injectedMetadata.getTheme.mockReturnValue({ version: 'v8', darkMode: true }); + themeService.setup({ injectedMetadata }); + const { theme$ } = themeService.start(); + const theme = await theme$.pipe(take(1)).toPromise(); + expect(theme).toEqual({ + darkMode: true, + }); + }); + }); +}); diff --git a/src/core/public/theme/theme_service.ts b/src/core/public/theme/theme_service.ts new file mode 100644 index 0000000000000..fc67ac4a595eb --- /dev/null +++ b/src/core/public/theme/theme_service.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Subject, Observable, of } from 'rxjs'; +import { shareReplay, takeUntil } from 'rxjs/operators'; +import { InjectedMetadataSetup } from '../injected_metadata'; +import type { CoreTheme, ThemeServiceSetup, ThemeServiceStart } from './types'; + +interface SetupDeps { + injectedMetadata: InjectedMetadataSetup; +} + +export class ThemeService { + private theme$?: Observable; + private stop$ = new Subject(); + + public setup({ injectedMetadata }: SetupDeps): ThemeServiceSetup { + const theme = injectedMetadata.getTheme(); + this.theme$ = of({ darkMode: theme.darkMode }); + + return { + theme$: this.theme$.pipe(takeUntil(this.stop$), shareReplay(1)), + }; + } + + public start(): ThemeServiceStart { + if (!this.theme$) { + throw new Error('setup must be called before start'); + } + + return { + theme$: this.theme$.pipe(takeUntil(this.stop$), shareReplay(1)), + }; + } + + public stop() { + this.stop$.next(); + } +} diff --git a/src/core/public/theme/types.ts b/src/core/public/theme/types.ts new file mode 100644 index 0000000000000..5f8672f3c902c --- /dev/null +++ b/src/core/public/theme/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Observable } from 'rxjs'; + +/** + * Contains all the required information to apply Kibana's theme at the various levels it can be used. + * + * @public + */ +export interface CoreTheme { + /** is dark mode enabled or not */ + readonly darkMode: boolean; +} + +/** + * @public + */ +export interface ThemeServiceSetup { + theme$: Observable; +} + +/** + * @public + */ +export interface ThemeServiceStart { + theme$: Observable; +} diff --git a/src/core/public/utils/core_context_provider.tsx b/src/core/public/utils/core_context_provider.tsx new file mode 100644 index 0000000000000..df070d26747d8 --- /dev/null +++ b/src/core/public/utils/core_context_provider.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { CoreThemeProvider } from '../theme/core_theme_provider'; +import type { ThemeServiceStart } from '../theme'; +import type { I18nStart } from '../i18n'; + +interface CoreContextProviderProps { + theme: ThemeServiceStart; + i18n: I18nStart; +} + +/** + * utility component exposing all the context providers required by core when integrating with react + **/ +export const CoreContextProvider: FC = ({ i18n, theme, children }) => { + return ( + + {children} + + ); +}; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index dfb76b3ff05cc..d28a8dcc37501 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -8,3 +8,4 @@ export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; +export { CoreContextProvider } from './core_context_provider'; diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 5ea65cd8d0c73..4abf24911808c 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -45,6 +45,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -100,6 +104,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -151,6 +159,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -202,6 +214,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -253,6 +269,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -308,6 +328,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -359,6 +383,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, @@ -410,6 +438,10 @@ Object { }, "publicBaseUrl": "http://myhost.com/mock-server-basepath", "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, "uiPlugins": Array [], "vars": Object {}, "version": Any, diff --git a/src/core/server/rendering/index.ts b/src/core/server/rendering/index.ts index b89ffd6519ab6..ce38cfab16de0 100644 --- a/src/core/server/rendering/index.ts +++ b/src/core/server/rendering/index.ts @@ -7,4 +7,12 @@ */ export { RenderingService } from './rendering_service'; -export * from './types'; +export type { + InjectedMetadata, + InternalRenderingServicePreboot, + InternalRenderingServiceSetup, + IRenderOptions, + RenderingMetadata, + RenderingPrebootDeps, + RenderingSetupDeps, +} from './types'; diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index b1c6971d3c42b..bcce7fb258758 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -124,6 +124,10 @@ export class RenderingService { i18n: { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, + theme: { + darkMode, + version: themeVersion, + }, csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, externalUrl: http.externalUrl, vars: vars ?? {}, diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index ca6bab0dff1f8..0cd4fe3547304 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -27,34 +27,41 @@ export interface RenderingMetadata { darkMode: boolean; themeVersion: ThemeVersion; stylesheetPaths: string[]; - injectedMetadata: { - version: string; - buildNumber: number; - branch: string; - basePath: string; - serverBasePath: string; - publicBaseUrl?: string; - env: { - mode: EnvironmentMode; - packageInfo: PackageInfo; - }; - anonymousStatusPage: boolean; - i18n: { - translationsUrl: string; - }; - csp: Pick; - externalUrl: { policy: IExternalUrlPolicy[] }; - vars: Record; - uiPlugins: Array<{ - id: string; - plugin: DiscoveredPlugin; - config?: Record; - }>; - legacyMetadata: { - uiSettings: { - defaults: Record; - user: Record>; - }; + injectedMetadata: InjectedMetadata; +} + +/** @internal */ +export interface InjectedMetadata { + version: string; + buildNumber: number; + branch: string; + basePath: string; + serverBasePath: string; + publicBaseUrl?: string; + env: { + mode: EnvironmentMode; + packageInfo: PackageInfo; + }; + anonymousStatusPage: boolean; + i18n: { + translationsUrl: string; + }; + theme: { + darkMode: boolean; + version: ThemeVersion; + }; + csp: Pick; + externalUrl: { policy: IExternalUrlPolicy[] }; + vars: Record; + uiPlugins: Array<{ + id: string; + plugin: DiscoveredPlugin; + config?: Record; + }>; + legacyMetadata: { + uiSettings: { + defaults: Record; + user: Record>; }; }; } diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index 510dabf3b32d4..1eb4a5f4e99e8 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -7,6 +7,7 @@ */ import React, { useEffect, useRef } from 'react'; +import { Observable } from 'rxjs'; import ReactDOM from 'react-dom'; import { HashRouter as Router, Switch, Route, Redirect } from 'react-router-dom'; import { EuiTab, EuiTabs, EuiToolTip } from '@elastic/eui'; @@ -14,7 +15,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme'; -import { ApplicationStart, ChromeStart, ScopedHistory } from 'src/core/public'; +import { ApplicationStart, ChromeStart, ScopedHistory, CoreTheme } from 'src/core/public'; import { DevToolApp } from './dev_tool'; @@ -22,6 +23,7 @@ interface DevToolsWrapperProps { devTools: readonly DevToolApp[]; activeDevTool: DevToolApp; updateRoute: (newRoute: string) => void; + theme$: Observable; } interface MountedDevToolDescriptor { @@ -30,7 +32,7 @@ interface MountedDevToolDescriptor { unmountHandler: () => void; } -function DevToolsWrapper({ devTools, activeDevTool, updateRoute }: DevToolsWrapperProps) { +function DevToolsWrapper({ devTools, activeDevTool, updateRoute, theme$ }: DevToolsWrapperProps) { const mountedTool = useRef(null); useEffect( @@ -84,6 +86,7 @@ function DevToolsWrapper({ devTools, activeDevTool, updateRoute }: DevToolsWrapp setHeaderActionMenu: () => undefined, // TODO: adapt to use Core's ScopedHistory history: {} as any, + theme$, }; const unmountHandler = await activeDevTool.mount(params); @@ -148,6 +151,7 @@ export function renderApp( application: ApplicationStart, chrome: ChromeStart, history: ScopedHistory, + theme$: Observable, devTools: readonly DevToolApp[] ) { if (redirectOnMissingCapabilities(application)) { @@ -175,6 +179,7 @@ export function renderApp( updateRoute={props.history.push} activeDevTool={devTool} devTools={devTools} + theme$={theme$} /> )} /> diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 5ccf614533164..e45e73c4b1b40 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -53,14 +53,14 @@ export class DevToolsPlugin implements Plugin { order: 9010, category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { - const { element, history } = params; + const { element, history, theme$ } = params; element.classList.add('devAppWrapper'); const [core] = await getStartServices(); const { application, chrome } = core; const { renderApp } = await import('./application'); - return renderApp(element, application, chrome, history, this.getSortedDevTools()); + return renderApp(element, application, chrome, history, theme$, this.getSortedDevTools()); }, }); diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 46f8599b996a2..2b39a6aa0e1fb 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -27,7 +27,9 @@ export * from './notifications'; export { Markdown, MarkdownSimple } from './markdown'; export { reactToUiComponent, uiToReactComponent } from './adapters'; export { toMountPoint, MountPointPortal } from './util'; +export type { ToMountPointOptions } from './util'; export { RedirectAppLinks } from './app_links'; +export { wrapWithTheme, KibanaThemeProvider } from './theme'; /** dummy plugin, we just want kibanaReact to have its own bundle */ export function plugin() { diff --git a/src/plugins/kibana_react/public/theme/index.ts b/src/plugins/kibana_react/public/theme/index.ts new file mode 100644 index 0000000000000..56e374946034e --- /dev/null +++ b/src/plugins/kibana_react/public/theme/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { wrapWithTheme } from './wrap_with_theme'; +export { KibanaThemeProvider } from './kibana_theme_provider'; +export type { EuiTheme } from './types'; diff --git a/src/plugins/kibana_react/public/theme/kibana_theme_provider.test.tsx b/src/plugins/kibana_react/public/theme/kibana_theme_provider.test.tsx new file mode 100644 index 0000000000000..f2d1484e4bb56 --- /dev/null +++ b/src/plugins/kibana_react/public/theme/kibana_theme_provider.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; +import type { ReactWrapper } from 'enzyme'; +import { of, BehaviorSubject } from 'rxjs'; +import { useEuiTheme } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import type { CoreTheme } from 'src/core/public'; +import { KibanaThemeProvider } from './kibana_theme_provider'; + +describe('KibanaThemeProvider', () => { + let euiTheme: ReturnType | undefined; + + beforeEach(() => { + euiTheme = undefined; + }); + + const flushPromises = async () => { + await new Promise(async (resolve, reject) => { + try { + setImmediate(() => resolve()); + } catch (error) { + reject(error); + } + }); + }; + + const InnerComponent: FC = () => { + const theme = useEuiTheme(); + useEffect(() => { + euiTheme = theme; + }, [theme]); + return
foo
; + }; + + const refresh = async (wrapper: ReactWrapper) => { + await act(async () => { + await flushPromises(); + wrapper.update(); + }); + }; + + it('exposes the EUI theme provider', async () => { + const coreTheme: CoreTheme = { darkMode: true }; + + const wrapper = mountWithIntl( + + + + ); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('DARK'); + }); + + it('propagates changes of the coreTheme observable', async () => { + const coreTheme$ = new BehaviorSubject({ darkMode: true }); + + const wrapper = mountWithIntl( + + + + ); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('DARK'); + + await act(async () => { + coreTheme$.next({ darkMode: false }); + }); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('LIGHT'); + }); +}); diff --git a/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx b/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx new file mode 100644 index 0000000000000..bd5d8c2ea8453 --- /dev/null +++ b/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { EuiThemeProvider } from '@elastic/eui'; +import type { CoreTheme } from '../../../../core/public'; +import { getColorMode } from './utils'; + +interface KibanaThemeProviderProps { + theme$: Observable; +} + +const defaultTheme: CoreTheme = { + darkMode: false, +}; + +export const KibanaThemeProvider: FC = ({ theme$, children }) => { + const theme = useObservable(theme$, defaultTheme); + const colorMode = useMemo(() => getColorMode(theme), [theme]); + return {children}; +}; diff --git a/src/plugins/kibana_react/public/theme/types.ts b/src/plugins/kibana_react/public/theme/types.ts new file mode 100644 index 0000000000000..4da25b7f4890a --- /dev/null +++ b/src/plugins/kibana_react/public/theme/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { useEuiTheme } from '@elastic/eui'; + +export type EuiTheme = ReturnType; diff --git a/src/plugins/kibana_react/public/theme/utils.test.ts b/src/plugins/kibana_react/public/theme/utils.test.ts new file mode 100644 index 0000000000000..57b37f4fb2f62 --- /dev/null +++ b/src/plugins/kibana_react/public/theme/utils.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getColorMode } from './utils'; + +describe('getColorMode', () => { + it('returns the correct `colorMode` when `darkMode` is enabled', () => { + expect(getColorMode({ darkMode: true })).toEqual('DARK'); + }); + + it('returns the correct `colorMode` when `darkMode` is disabled', () => { + expect(getColorMode({ darkMode: false })).toEqual('LIGHT'); + }); +}); diff --git a/src/plugins/kibana_react/public/theme/utils.ts b/src/plugins/kibana_react/public/theme/utils.ts new file mode 100644 index 0000000000000..e85bc78333255 --- /dev/null +++ b/src/plugins/kibana_react/public/theme/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { EuiThemeColorMode } from '@elastic/eui/src/services/theme/types'; +import type { CoreTheme } from '../../../../core/public'; + +export const getColorMode = (theme: CoreTheme): EuiThemeColorMode => { + // COLOR_MODES_STANDARD is not exported from eui + return theme.darkMode ? 'DARK' : 'LIGHT'; +}; diff --git a/src/plugins/kibana_react/public/theme/wrap_with_theme.tsx b/src/plugins/kibana_react/public/theme/wrap_with_theme.tsx new file mode 100644 index 0000000000000..b07b93653c3e5 --- /dev/null +++ b/src/plugins/kibana_react/public/theme/wrap_with_theme.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Observable } from 'rxjs'; +import type { CoreTheme } from '../../../../core/public'; +import { KibanaThemeProvider } from './kibana_theme_provider'; + +export const wrapWithTheme = ( + node: React.ReactNode, + theme$: Observable +): React.ReactElement => { + return {node}; +}; diff --git a/src/plugins/kibana_react/public/util/index.ts b/src/plugins/kibana_react/public/util/index.ts index e4d6ffc83f7bf..0deada2d02c78 100644 --- a/src/plugins/kibana_react/public/util/index.ts +++ b/src/plugins/kibana_react/public/util/index.ts @@ -7,5 +7,6 @@ */ export { toMountPoint } from './to_mount_point'; +export type { ToMountPointOptions } from './to_mount_point'; export { MountPointPortal } from './mount_point_portal'; export { useIfMounted } from './utils'; diff --git a/src/plugins/kibana_react/public/util/to_mount_point.test.tsx b/src/plugins/kibana_react/public/util/to_mount_point.test.tsx new file mode 100644 index 0000000000000..7f5bd0789cb12 --- /dev/null +++ b/src/plugins/kibana_react/public/util/to_mount_point.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; +import { of, BehaviorSubject } from 'rxjs'; +import { useEuiTheme } from '@elastic/eui'; +import type { CoreTheme } from 'src/core/public'; +import { toMountPoint } from './to_mount_point'; + +describe('toMountPoint', () => { + let euiTheme: ReturnType | undefined; + + beforeEach(() => { + euiTheme = undefined; + }); + + const InnerComponent: FC = () => { + const theme = useEuiTheme(); + useEffect(() => { + euiTheme = theme; + }, [theme]); + return
foo
; + }; + + const flushPromises = async () => { + await new Promise(async (resolve, reject) => { + try { + setTimeout(() => resolve(), 20); + } catch (error) { + reject(error); + } + }); + }; + + it('exposes the euiTheme when `theme$` is provided', async () => { + const theme$ = of({ darkMode: true }); + const mount = toMountPoint(, { theme$ }); + + const targetEl = document.createElement('div'); + mount(targetEl); + + await flushPromises(); + + expect(euiTheme!.colorMode).toEqual('DARK'); + }); + + it('propagates changes of the theme$ observable', async () => { + const theme$ = new BehaviorSubject({ darkMode: true }); + + const mount = toMountPoint(, { theme$ }); + + const targetEl = document.createElement('div'); + mount(targetEl); + + await flushPromises(); + + expect(euiTheme!.colorMode).toEqual('DARK'); + + await act(async () => { + theme$.next({ darkMode: false }); + }); + await flushPromises(); + + expect(euiTheme!.colorMode).toEqual('LIGHT'); + }); +}); diff --git a/src/plugins/kibana_react/public/util/to_mount_point.tsx b/src/plugins/kibana_react/public/util/to_mount_point.tsx index 8ebc73d04feb2..b43894be31c8d 100644 --- a/src/plugins/kibana_react/public/util/to_mount_point.tsx +++ b/src/plugins/kibana_react/public/util/to_mount_point.tsx @@ -8,17 +8,27 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Observable } from 'rxjs'; import { I18nProvider } from '@kbn/i18n/react'; -import { MountPoint } from 'kibana/public'; +import type { MountPoint, CoreTheme } from 'kibana/public'; +import { KibanaThemeProvider } from '../theme'; + +export interface ToMountPointOptions { + theme$?: Observable; +} /** * MountPoint converter for react nodes. * * @param node to get a mount point for */ -export const toMountPoint = (node: React.ReactNode): MountPoint => { +export const toMountPoint = ( + node: React.ReactNode, + { theme$ }: ToMountPointOptions = {} +): MountPoint => { + const content = theme$ ? {node} : node; const mount = (element: HTMLElement) => { - ReactDOM.render({node}, element); + ReactDOM.render({content}, element); return () => ReactDOM.unmountComponentAtNode(element); }; // only used for tests and snapshots serialization diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index cbebc72b20c5a..948c75cd1ef9b 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -13,6 +13,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; +import { wrapWithTheme } from '../../../kibana_react/public'; import { ManagementAppMountParams } from '../../../management/public'; import type { SavedObjectManagementTypeInfo } from '../../common/types'; import { StartDependencies, SavedObjectsManagementPluginStart } from '../plugin'; @@ -36,6 +37,7 @@ export const mountManagementSection = async ({ core, mountParams }: MountParams) await core.getStartServices(); const { capabilities } = coreStart.application; const { element, history, setBreadcrumbs } = mountParams; + const { theme$ } = core.theme; if (!allowedObjectTypes) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -54,39 +56,42 @@ export const mountManagementSection = async ({ core, mountParams }: MountParams) }; ReactDOM.render( - - - - - - }> - - - - - - - }> - - - - - - - , + wrapWithTheme( + + + + + + }> + + + + + + + }> + + + + + + + , + theme$ + ), element ); diff --git a/x-pack/plugins/fleet/storybook/context/index.tsx b/x-pack/plugins/fleet/storybook/context/index.tsx index 76425540c2fbc..ae18f393970f7 100644 --- a/x-pack/plugins/fleet/storybook/context/index.tsx +++ b/x-pack/plugins/fleet/storybook/context/index.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo, useCallback } from 'react'; +import { EMPTY } from 'rxjs'; import type { StoryContext } from '@storybook/react'; import { createBrowserHistory } from 'history'; @@ -69,6 +70,9 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ notifications: getNotifications(), share: getShare(), uiSettings: getUiSettings(), + theme: { + theme$: EMPTY, + }, }), [isCloudEnabled] ); diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 506b919b16416..6549e892cab12 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -41,14 +41,20 @@ const sampleAPMIndices = { transaction: 'apm-*' } as ApmIndicesConfig; const withCore = makeDecorator({ name: 'withCore', parameterName: 'core', - wrapper: (storyFn, context, { options }) => { + wrapper: (storyFn, context, { options: { theme, ...options } }) => { unregisterAll(); const KibanaReactContext = createKibanaReactContext({ application: { getUrlForApp: () => '' }, - chrome: { docTitle: { change: () => {} } }, + chrome: { + docTitle: { + change: () => {}, + }, + }, uiSettings: { get: () => [] }, - usageCollection: { reportUiCounter: () => {} }, + usageCollection: { + reportUiCounter: () => {}, + }, } as unknown as Partial); return ( @@ -66,7 +72,12 @@ const withCore = makeDecorator({ plugins: { data: { query: { - timefilter: { timefilter: { setTime: () => {}, getTime: () => ({}) } }, + timefilter: { + timefilter: { + setTime: () => {}, + getTime: () => ({}), + }, + }, }, }, } as unknown as ObservabilityPublicPluginsStart, diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index 2dfc7fac90a29..d3a4c36ba6b93 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -9,7 +9,7 @@ jest.mock('./account_management_page'); import type { AppMount } from 'src/core/public'; import { AppNavLinkStatus } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import { UserAPIClient } from '../management'; import { securityMock } from '../mocks'; @@ -58,6 +58,7 @@ describe('accountManagementApp', () => { onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), }); expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index fc3565adf5caf..f192f298009f7 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -8,7 +8,7 @@ jest.mock('./access_agreement_page'); import type { AppMount } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import { accessAgreementApp } from './access_agreement_app'; @@ -51,6 +51,7 @@ describe('accessAgreementApp', () => { onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), }); const mockRenderApp = jest.requireMock('./access_agreement_page').renderAccessAgreementPage; diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index 6c6071e9af7ee..a88195c6fe8a6 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -8,7 +8,7 @@ jest.mock('./logged_out_page'); import type { AppMount } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import { loggedOutApp } from './logged_out_app'; @@ -49,6 +49,7 @@ describe('loggedOutApp', () => { onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), }); const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage; diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index 2ee759c99f740..5406494067d0e 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -8,7 +8,7 @@ jest.mock('./login_page'); import type { AppMount } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import { loginApp } from './login_app'; @@ -54,6 +54,7 @@ describe('loginApp', () => { onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), }); const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 8fcccdf916e93..f60dcfb3e7abe 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -6,7 +6,7 @@ */ import type { AppMount } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import { logoutApp } from './logout_app'; @@ -55,6 +55,7 @@ describe('logoutApp', () => { onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), }); expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 37a46a0dcad8a..95497bdc1fb54 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -8,7 +8,7 @@ jest.mock('./overwritten_session_page'); import type { AppMount } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import { securityMock } from '../../mocks'; import { overwrittenSessionApp } from './overwritten_session_app'; @@ -56,6 +56,7 @@ describe('overwrittenSessionApp', () => { onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), }); const mockRenderApp = jest.requireMock(