Browser-side theme service: add synchronous accessor for current theme (#173535)

## Summary

Related to https://github.com/elastic/kibana/issues/173529.

Very likely, some of the work for
https://github.com/elastic/kibana/issues/173529 will require a
synchronous API to retrieve the current theme. Opening this PR now to
avoid blocking the issue.
This commit is contained in:
Pierre Gayvallet 2023-12-19 15:14:31 +01:00 committed by GitHub
parent 3ea5865a0a
commit 40b354958f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 104 additions and 61 deletions

View file

@ -19,6 +19,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Rx.Observable,
}
}

View file

@ -25,6 +25,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},

View file

@ -33,6 +33,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -147,6 +148,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -254,6 +256,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},

View file

@ -100,6 +100,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -361,6 +362,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -677,6 +679,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -914,6 +917,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1156,6 +1160,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1393,6 +1398,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1438,6 +1444,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1581,6 +1588,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1688,6 +1696,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1800,6 +1809,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},
@ -1907,6 +1917,7 @@ Array [
}
theme={
Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { take } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks';
import { ThemeService } from './theme_service';
@ -23,7 +23,16 @@ describe('ThemeService', () => {
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();
const theme = await firstValueFrom(theme$);
expect(theme).toEqual({
darkMode: true,
});
});
it('#getTheme() returns the current theme', async () => {
injectedMetadata.getTheme.mockReturnValue({ version: 'v8', darkMode: true });
const setup = themeService.setup({ injectedMetadata });
const theme = setup.getTheme();
expect(theme).toEqual({
darkMode: true,
});
@ -41,7 +50,17 @@ describe('ThemeService', () => {
injectedMetadata.getTheme.mockReturnValue({ version: 'v8', darkMode: true });
themeService.setup({ injectedMetadata });
const { theme$ } = themeService.start();
const theme = await theme$.pipe(take(1)).toPromise();
const theme = await firstValueFrom(theme$);
expect(theme).toEqual({
darkMode: true,
});
});
it('#getTheme() returns the current theme', async () => {
injectedMetadata.getTheme.mockReturnValue({ version: 'v8', darkMode: true });
themeService.setup({ injectedMetadata });
const start = themeService.start();
const theme = start.getTheme();
expect(theme).toEqual({
darkMode: true,
});

View file

@ -6,8 +6,7 @@
* Side Public License, v 1.
*/
import { Subject, Observable, of } from 'rxjs';
import { shareReplay, takeUntil } from 'rxjs/operators';
import { Subject, of } from 'rxjs';
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
import type { CoreTheme, ThemeServiceSetup, ThemeServiceStart } from '@kbn/core-theme-browser';
@ -18,26 +17,27 @@ export interface ThemeServiceSetupDeps {
/** @internal */
export class ThemeService {
private theme$?: Observable<CoreTheme>;
private contract?: ThemeServiceSetup;
private stop$ = new Subject<void>();
public setup({ injectedMetadata }: ThemeServiceSetupDeps): ThemeServiceSetup {
const theme = injectedMetadata.getTheme();
this.theme$ = of({ darkMode: theme.darkMode });
const themeMeta = injectedMetadata.getTheme();
const theme: CoreTheme = { darkMode: themeMeta.darkMode };
return {
theme$: this.theme$.pipe(takeUntil(this.stop$), shareReplay(1)),
this.contract = {
theme$: of(theme),
getTheme: () => theme,
};
return this.contract;
}
public start(): ThemeServiceStart {
if (!this.theme$) {
if (!this.contract) {
throw new Error('setup must be called before start');
}
return {
theme$: this.theme$.pipe(takeUntil(this.stop$), shareReplay(1)),
};
return this.contract;
}
public stop() {

View file

@ -8,7 +8,7 @@
import { of } from 'rxjs';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { ThemeServiceSetup, ThemeServiceStart, CoreTheme } from '@kbn/core-theme-browser';
import type { ThemeServiceSetup, CoreTheme } from '@kbn/core-theme-browser';
import type { ThemeService } from '@kbn/core-theme-browser-internal';
const mockTheme: CoreTheme = {
@ -19,22 +19,16 @@ const createThemeMock = (): CoreTheme => {
return { ...mockTheme };
};
const createTheme$Mock = () => {
return of(createThemeMock());
const createTheme$Mock = (theme: CoreTheme = createThemeMock()) => {
return of(theme);
};
const createThemeSetupMock = () => {
const setupMock: jest.Mocked<ThemeServiceSetup> = {
theme$: createTheme$Mock(),
const createThemeContractMock = (theme: CoreTheme = createThemeMock()) => {
const themeMock: jest.Mocked<ThemeServiceSetup> = {
theme$: createTheme$Mock(theme),
getTheme: jest.fn().mockReturnValue(theme),
};
return setupMock;
};
const createThemeStartMock = () => {
const startMock: jest.Mocked<ThemeServiceStart> = {
theme$: createTheme$Mock(),
};
return startMock;
return themeMock;
};
type ThemeServiceContract = PublicMethodsOf<ThemeService>;
@ -46,16 +40,16 @@ const createServiceMock = () => {
stop: jest.fn(),
};
mocked.setup.mockReturnValue(createThemeSetupMock());
mocked.start.mockReturnValue(createThemeStartMock());
mocked.setup.mockReturnValue(createThemeContractMock());
mocked.start.mockReturnValue(createThemeContractMock());
return mocked;
};
export const themeServiceMock = {
create: createServiceMock,
createSetupContract: createThemeSetupMock,
createStartContract: createThemeStartMock,
createSetupContract: createThemeContractMock,
createStartContract: createThemeContractMock,
createTheme: createThemeMock,
createTheme$: createTheme$Mock,
};

View file

@ -22,12 +22,21 @@ export interface CoreTheme {
* @public
*/
export interface ThemeServiceSetup {
/**
* An observable of the currently active theme.
* Note that the observable has a replay effect, so it will emit once during subscriptions.
*/
theme$: Observable<CoreTheme>;
/**
* Returns the theme currently in use.
* Note that when possible, using the `theme$` observable instead is strongly encouraged, as
* it will allow to react to dynamic theme switch (even if those are not implemented at the moment)
*/
getTheme(): CoreTheme;
}
/**
* @public
*/
export interface ThemeServiceStart {
theme$: Observable<CoreTheme>;
}
export type ThemeServiceStart = ThemeServiceSetup;

View file

@ -8,7 +8,7 @@
import { of } from 'rxjs';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { chromeServiceMock, coreMock } from '@kbn/core/public/mocks';
import { chromeServiceMock, coreMock, themeServiceMock } from '@kbn/core/public/mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { IUiSettingsClient, ToastsStart } from '@kbn/core/public';
@ -41,10 +41,7 @@ export function createServicesMock() {
...uiSettingsMock,
};
const theme = {
theme$: of({ darkMode: false }),
};
const theme = themeServiceMock.createSetupContract({ darkMode: false });
corePluginMock.theme = theme;
const dataPlugin = dataPluginMock.createStartContract();

View file

@ -5,12 +5,11 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { from } from 'rxjs';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { ThemeService } from '@kbn/charts-plugin/public/services';
const theme = new ThemeService();
theme.init({
theme$: from([{ darkMode: false }]),
});
theme.init(themeServiceMock.createSetupContract({ darkMode: false }));
export { theme };

View file

@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
import { coreMock } from '@kbn/core/public/mocks';
import { coreMock, themeServiceMock } from '@kbn/core/public/mocks';
import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { Observable } from 'rxjs';
import { ControlsCoreService } from './types';
export type CoreServiceFactory = PluginServiceFactory<ControlsCoreService>;
@ -16,9 +15,7 @@ export type CoreServiceFactory = PluginServiceFactory<ControlsCoreService>;
export const coreServiceFactory: CoreServiceFactory = () => {
const corePluginMock = coreMock.createStart();
return {
theme: {
theme$: new Observable((subscriber) => subscriber.next({ darkMode: false })),
},
theme: themeServiceMock.createSetupContract(),
i18n: corePluginMock.i18n,
};
};

View file

@ -11,7 +11,12 @@ import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks';
import { chromeServiceMock, coreMock, docLinksServiceMock } from '@kbn/core/public/mocks';
import {
chromeServiceMock,
coreMock,
docLinksServiceMock,
themeServiceMock,
} from '@kbn/core/public/mocks';
import {
CONTEXT_STEP_SETTING,
DEFAULT_COLUMNS_SETTING,
@ -133,9 +138,7 @@ export function createDiscoverServicesMock(): DiscoverServices {
...uiSettingsMock,
};
const theme = {
theme$: of({ darkMode: false }),
};
const theme = themeServiceMock.createSetupContract({ darkMode: false });
corePluginMock.theme = theme;

View file

@ -23,6 +23,7 @@ import { toMountPoint as _toMountPoint } from '@kbn/react-kibana-mount';
// and will be removed when the deprecated usages are removed.
const themeStart: ThemeServiceStart = {
theme$: new Observable((subscriber) => subscriber.next(defaultTheme)),
getTheme: () => defaultTheme,
};
// The `i18n` start contract should always be included to ensure

View file

@ -35,6 +35,7 @@ exports[`SavedObjectEdition should render normally 1`] = `
},
},
"theme": Object {
"getTheme": [MockFunction],
"theme$": Observable {
"_subscribe": [Function],
},

View file

@ -93,6 +93,7 @@ export const StorybookContext: React.FC<{ storyContext?: Parameters<DecoratorFn>
settings: getSettings(),
theme: {
theme$: EMPTY,
getTheme: () => ({ darkMode: false }),
},
plugins: {} as unknown as PluginsServiceStart,
authz: {

View file

@ -7,8 +7,13 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Observable } from 'rxjs';
import { CoreSetup, CoreStart, HttpSetup, ChromeStart, CoreTheme } from '@kbn/core/public';
import type {
CoreSetup,
CoreStart,
HttpSetup,
ChromeStart,
ThemeServiceStart,
} from '@kbn/core/public';
import { createKibanaReactContext, KibanaThemeProvider } from '../shared_imports';
@ -23,12 +28,12 @@ interface AppDependencies {
settings: CoreStart['settings'];
links: Links;
chrome: ChromeStart;
theme$: Observable<CoreTheme>;
theme: ThemeServiceStart;
}
export function renderApp(
element: HTMLElement | null,
{ http, I18nContext, uiSettings, links, chrome, theme$, settings }: AppDependencies
{ http, I18nContext, uiSettings, links, chrome, theme, settings }: AppDependencies
) {
if (!element) {
return () => undefined;
@ -36,11 +41,11 @@ export function renderApp(
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
uiSettings,
settings,
theme: { theme$ },
theme,
});
render(
<I18nContext>
<KibanaThemeProvider theme$={theme$}>
<KibanaThemeProvider theme$={theme.theme$}>
<KibanaReactContextProvider>
<AppContextProvider value={{ http, links, chrome }}>
<Main />

View file

@ -48,7 +48,7 @@ export class PainlessLabUIPlugin implements Plugin<void, void, PluginDependencie
}),
enableRouting: false,
disabled: false,
mount: async ({ element, theme$ }) => {
mount: async ({ element }) => {
const [core] = await getStartServices();
const {
@ -57,6 +57,7 @@ export class PainlessLabUIPlugin implements Plugin<void, void, PluginDependencie
docLinks,
chrome,
settings,
theme,
} = core;
const license = await firstValueFrom(licensing.license$);
@ -75,7 +76,7 @@ export class PainlessLabUIPlugin implements Plugin<void, void, PluginDependencie
uiSettings,
links: getLinks(docLinks),
chrome,
theme$,
theme,
settings,
});