[SharedUX] Custom branding service (#148273)

## Summary

This PR adds a new `CustomBranding` service and exposes it from core,
both on the server and client side. The purpose of the service is to
retrieve custom branding properties and propagate them to the
appropriate core service (`chrome` on the client-side and `rendering` on
the server-side). The client side receives server-side properties
through `injectedMetadata`.
Note that the service itself is not responsible for reading the
properties from `uiSettings`; this task is offloaded to `customBranding`
plugin.

I deployed one of the previous commits
[here]([ttps://majagrubic-pr-148273-custom-branding-service-server.kbndev.co/aiy/app/home#/](https://majagrubic-pr-148273-custom-branding-service-server.kbndev.co/aiy/app/home#/)),
so you can see a custom logo set (client-side) and page title set
(server-side).


### Checklist

Delete any items that are not applicable to this PR.

- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [X]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
~- [] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard
accessibility](https://webaim.org/techniques/keyboard/))~
~- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~
~- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~
~- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~
~- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Maja Grubic 2023-01-11 15:28:10 +01:00 committed by GitHub
parent d2788bcad7
commit 4522e04287
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
114 changed files with 1252 additions and 52 deletions

View file

@ -15,6 +15,7 @@ import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import type { AppMountParameters, AppUpdater } from '@kbn/core-application-browser';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import type { MountPoint } from '@kbn/core-mount-utils-browser';
import type { MockLifecycle } from '../src/test_helpers/test_types';
import { ApplicationService } from '../src/application_service';
@ -48,6 +49,7 @@ describe('ApplicationService', () => {
http,
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
};
service = new ApplicationService();
});

View file

@ -67,6 +67,14 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = `
"thrownError": null,
}
}
hasCustomBranding$={
Observable {
"operator": [Function],
"source": Observable {
"_subscribe": [Function],
},
}
}
history={
Object {
"push": [MockFunction],

View file

@ -20,6 +20,7 @@ import { mount, shallow } from 'enzyme';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { MockLifecycle } from './test_helpers/test_types';
import { ApplicationService } from './application_service';
import {
@ -56,6 +57,7 @@ describe('#setup()', () => {
http,
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
};
service = new ApplicationService();
});
@ -480,6 +482,7 @@ describe('#start()', () => {
http,
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
};
service = new ApplicationService();
});
@ -1189,6 +1192,7 @@ describe('#stop()', () => {
http,
overlays: overlayServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
customBranding: customBrandingServiceMock.createStartContract(),
};
service = new ApplicationService();
});

View file

@ -29,6 +29,7 @@ import type {
} from '@kbn/core-application-browser';
import { CapabilitiesService } from '@kbn/core-capabilities-browser-internal';
import { AppStatus, AppNavLinkStatus } from '@kbn/core-application-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import { AppRouter } from './ui';
import type { InternalApplicationSetup, InternalApplicationStart, Mounter } from './types';
@ -47,6 +48,7 @@ export interface StartDeps {
http: HttpStart;
theme: ThemeServiceStart;
overlays: OverlayStart;
customBranding: CustomBrandingStart;
}
function filterAvailable<T>(m: Map<string, T>, capabilities: Capabilities) {
@ -105,6 +107,7 @@ export class ApplicationService {
private openInNewTab?: (url: string) => void;
private redirectTo?: (url: string) => void;
private overlayStart$ = new Subject<OverlayStart>();
private hasCustomBranding$: Observable<boolean> | undefined;
public setup({
http: { basePath },
@ -204,13 +207,18 @@ export class ApplicationService {
};
}
public async start({ http, overlays, theme }: StartDeps): Promise<InternalApplicationStart> {
public async start({
http,
overlays,
theme,
customBranding,
}: StartDeps): Promise<InternalApplicationStart> {
if (!this.redirectTo) {
throw new Error('ApplicationService#setup() must be invoked before start.');
}
this.overlayStart$.next(overlays);
this.hasCustomBranding$ = customBranding.hasCustomBranding$.pipe(takeUntil(this.stop$));
const httpLoadingCount$ = new BehaviorSubject(0);
http.addLoadingCountSource(httpLoadingCount$);
@ -345,6 +353,7 @@ export class ApplicationService {
setAppLeaveHandler={this.setAppLeaveHandler}
setAppActionMenu={this.setAppActionMenu}
setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)}
hasCustomBranding$={this.hasCustomBranding$}
/>
);
},

View file

@ -184,6 +184,30 @@ describe('AppContainer', () => {
expect(setIsMounting).toHaveBeenLastCalledWith(false);
});
it('should show plain spinner', async () => {
const [waitPromise] = createResolver();
const mounter = createMounter(waitPromise);
const wrapper = mountWithIntl(
<AppContainer
appPath={`/app/${appId}`}
appId={appId}
appStatus={AppStatus.accessible}
mounter={mounter}
setAppLeaveHandler={setAppLeaveHandler}
setAppActionMenu={setAppActionMenu}
setIsMounting={setIsMounting}
createScopedHistory={(appPath: string) =>
// Create a history using the appPath as the current location
new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath)
}
theme$={theme$}
showPlainSpinner={true}
/>
);
expect(wrapper.find('[data-test-subj="appContainer-loadingSpinner"]').exists()).toBeTruthy();
});
it('should call setIsMounting(false) if mounting throws', async () => {
const [waitPromise, resolvePromise] = createResolver();
const mounter = {

View file

@ -10,7 +10,7 @@ import './app_container.scss';
import { Observable } from 'rxjs';
import React, { Fragment, FC, useLayoutEffect, useRef, useState, MutableRefObject } from 'react';
import { EuiLoadingElastic } from '@elastic/eui';
import { EuiLoadingElastic, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { CoreTheme } from '@kbn/core-theme-browser';
@ -36,6 +36,7 @@ interface Props {
setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void;
createScopedHistory: (appUrl: string) => ScopedHistory;
setIsMounting: (isMounting: boolean) => void;
showPlainSpinner?: boolean;
}
export const AppContainer: FC<Props> = ({
@ -48,6 +49,7 @@ export const AppContainer: FC<Props> = ({
appStatus,
setIsMounting,
theme$,
showPlainSpinner,
}: Props) => {
const [showSpinner, setShowSpinner] = useState(true);
const [appNotFound, setAppNotFound] = useState(false);
@ -114,13 +116,18 @@ export const AppContainer: FC<Props> = ({
return (
<Fragment>
{appNotFound && <AppNotFound />}
{showSpinner && !appNotFound && <AppLoadingPlaceholder />}
{showSpinner && !appNotFound && (
<AppLoadingPlaceholder showPlainSpinner={Boolean(showPlainSpinner)} />
)}
<div className={APP_WRAPPER_CLASS} key={appId} ref={elementRef} aria-busy={showSpinner} />
</Fragment>
);
};
const AppLoadingPlaceholder: FC = () => {
const AppLoadingPlaceholder: FC<{ showPlainSpinner: boolean }> = ({ showPlainSpinner }) => {
if (showPlainSpinner) {
return <EuiLoadingSpinner size={'xxl'} data-test-subj="appContainer-loadingSpinner" />;
}
return (
<EuiLoadingElastic
className="appContainer__loading"

View file

@ -9,7 +9,7 @@
import React, { FunctionComponent, useMemo } from 'react';
import { Route, RouteComponentProps, Router, Switch } from 'react-router-dom';
import { History } from 'history';
import { Observable } from 'rxjs';
import { EMPTY, Observable } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import type { CoreTheme } from '@kbn/core-theme-browser';
@ -27,6 +27,7 @@ interface Props {
setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void;
setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void;
setIsMounting: (isMounting: boolean) => void;
hasCustomBranding$?: Observable<boolean>;
}
interface Params {
@ -41,6 +42,7 @@ export const AppRouter: FunctionComponent<Props> = ({
setAppActionMenu,
appStatuses$,
setIsMounting,
hasCustomBranding$,
}) => {
const appStatuses = useObservable(appStatuses$, new Map());
const createScopedHistory = useMemo(
@ -48,6 +50,8 @@ export const AppRouter: FunctionComponent<Props> = ({
[history]
);
const showPlainSpinner = useObservable(hasCustomBranding$ ?? EMPTY, false);
return (
<Router history={history}>
<Switch>
@ -61,7 +65,15 @@ export const AppRouter: FunctionComponent<Props> = ({
appPath={path}
appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible}
createScopedHistory={createScopedHistory}
{...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting, theme$ }}
{...{
appId,
mounter,
setAppLeaveHandler,
setAppActionMenu,
setIsMounting,
theme$,
showPlainSpinner,
}}
/>
)}
/>
@ -83,7 +95,14 @@ export const AppRouter: FunctionComponent<Props> = ({
appId={id ?? appId}
appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible}
createScopedHistory={createScopedHistory}
{...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting, theme$ }}
{...{
mounter,
setAppLeaveHandler,
setAppActionMenu,
setIsMounting,
theme$,
showPlainSpinner,
}}
/>
);
}}

View file

@ -31,6 +31,8 @@
"@kbn/core-theme-browser-mocks",
"@kbn/core-http-browser-internal",
"@kbn/test-jest-helpers",
"@kbn/core-custom-branding-browser",
"@kbn/core-custom-branding-browser-mocks",
],
"exclude": [
"target/**/*",