[EBT] Add page title to browser-side context (#159936)

## Summary

Part of https://github.com/elastic/kibana/issues/149249

Add a new EBT context providing the page_title field to events.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pierre Gayvallet 2023-06-20 07:31:25 -04:00 committed by GitHub
parent 06a00d6b59
commit 27df64c2bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 266 additions and 35 deletions

View file

@ -0,0 +1,14 @@
/*
* 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 const registerAnalyticsContextProviderMock = jest.fn();
jest.doMock('./register_analytics_context_provider', () => {
return {
registerAnalyticsContextProvider: registerAnalyticsContextProviderMock,
};
});

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { registerAnalyticsContextProviderMock } from './chrome_service.test.mocks';
import { shallow, mount } from 'enzyme';
import React from 'react';
import * as Rx from 'rxjs';
@ -18,6 +19,7 @@ import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { getAppInfo } from '@kbn/core-application-browser-internal';
import { findTestSubject } from '@kbn/test-jest-helpers';
import { ChromeService } from './chrome_service';
@ -84,15 +86,19 @@ async function start({
startDeps.injectedMetadata.getCspConfig.mockReturnValue(cspConfigMock);
}
await service.setup({ analytics: analyticsServiceMock.createAnalyticsServiceSetup() });
const chromeStart = await service.start(startDeps);
return {
service,
startDeps,
chrome: await service.start(startDeps),
chrome: chromeStart,
};
}
beforeEach(() => {
store.clear();
registerAnalyticsContextProviderMock.mockReset();
window.history.pushState(undefined, '', '#/home?a=b');
});
@ -100,6 +106,20 @@ afterAll(() => {
(window as any).localStorage = originalLocalStorage;
});
describe('setup', () => {
it('calls registerAnalyticsContextProvider with the correct parameters', async () => {
const service = new ChromeService(defaultStartTestOptions({}));
const analytics = analyticsServiceMock.createAnalyticsServiceSetup();
await service.setup({ analytics });
expect(registerAnalyticsContextProviderMock).toHaveBeenCalledTimes(1);
expect(registerAnalyticsContextProviderMock).toHaveBeenCalledWith(
analytics,
expect.any(Object)
);
});
});
describe('start', () => {
it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', async () => {
const { startDeps } = await start({

View file

@ -14,6 +14,7 @@ import { parse } from 'url';
import { EuiLink } from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { HttpStart } from '@kbn/core-http-browser';
import { mountReactNode } from '@kbn/core-mount-utils-browser-internal';
@ -40,6 +41,7 @@ import { NavLinksService } from './nav_links';
import { ProjectNavigationService } from './project_navigation';
import { RecentlyAccessedService } from './recently_accessed';
import { Header, ProjectHeader, ProjectSideNavigation } from './ui';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
import type { InternalChromeStart } from './types';
const IS_LOCKED_KEY = 'core.chrome.isLocked';
@ -50,6 +52,10 @@ interface ConstructorParams {
kibanaVersion: string;
}
export interface SetupDeps {
analytics: AnalyticsServiceSetup;
}
export interface StartDeps {
application: InternalApplicationStart;
docLinks: DocLinksStart;
@ -104,6 +110,11 @@ export class ChromeService {
);
}
public setup({ analytics }: SetupDeps) {
const docTitle = this.docTitle.setup({ document: window.document });
registerAnalyticsContextProvider(analytics, docTitle.title$);
}
public async start({
application,
docLinks,
@ -155,7 +166,7 @@ export class ChromeService {
const navLinks = this.navLinks.start({ application, http });
const projectNavigation = this.projectNavigation.start({ application, navLinks });
const recentlyAccessed = await this.recentlyAccessed.start({ http });
const docTitle = this.docTitle.start({ document: window.document });
const docTitle = this.docTitle.start();
const { customBranding$ } = customBranding;
// erase chrome fields from a previous app while switching to a next app

View file

@ -10,47 +10,128 @@ import { DocTitleService } from './doc_title_service';
describe('DocTitleService', () => {
const defaultTitle = 'KibanaTest';
const document = { title: '' };
let document: { title: string };
const getStart = (title: string = defaultTitle) => {
document.title = title;
return new DocTitleService().start({ document });
const docTitle = new DocTitleService();
docTitle.setup({ document });
return docTitle.start();
};
beforeEach(() => {
document.title = defaultTitle;
document = { title: defaultTitle };
});
describe('#change()', () => {
it('changes the title of the document', async () => {
getStart().change('TitleA');
expect(document.title).toEqual('TitleA - KibanaTest');
});
describe('#setup', () => {
describe('title$', () => {
it('emits with the initial title', () => {
document.title = 'Kibana';
const docTitle = new DocTitleService();
const { title$ } = docTitle.setup({ document });
docTitle.start();
it('appends the baseTitle to the title', async () => {
const start = getStart('BaseTitle');
start.change('TitleA');
expect(document.title).toEqual('TitleA - BaseTitle');
start.change('TitleB');
expect(document.title).toEqual('TitleB - BaseTitle');
});
const titles: string[] = [];
title$.subscribe((title) => {
titles.push(title);
});
it('accepts string arrays as input', async () => {
const start = getStart();
start.change(['partA', 'partB']);
expect(document.title).toEqual(`partA - partB - ${defaultTitle}`);
start.change(['partA', 'partB', 'partC']);
expect(document.title).toEqual(`partA - partB - partC - ${defaultTitle}`);
expect(titles).toEqual(['Kibana']);
});
it('emits when the title changes', () => {
document.title = 'Kibana';
const docTitle = new DocTitleService();
const { title$ } = docTitle.setup({ document });
const { change } = docTitle.start();
const titles: string[] = [];
title$.subscribe((title) => {
titles.push(title);
});
change('title 2');
change('title 3');
expect(titles).toEqual(['Kibana', 'title 2 - Kibana', 'title 3 - Kibana']);
});
it('emits when the title is reset', () => {
document.title = 'Kibana';
const docTitle = new DocTitleService();
const { title$ } = docTitle.setup({ document });
const { change, reset } = docTitle.start();
const titles: string[] = [];
title$.subscribe((title) => {
titles.push(title);
});
change('title 2');
reset();
expect(titles).toEqual(['Kibana', 'title 2 - Kibana', 'Kibana']);
});
it('only emits on unique titles', () => {
document.title = 'Kibana';
const docTitle = new DocTitleService();
const { title$ } = docTitle.setup({ document });
const { change } = docTitle.start();
const titles: string[] = [];
title$.subscribe((title) => {
titles.push(title);
});
change('title 2');
change('title 2');
change('title 3');
expect(titles).toEqual(['Kibana', 'title 2 - Kibana', 'title 3 - Kibana']);
});
});
});
describe('#reset()', () => {
it('resets the title to the initial value', async () => {
const start = getStart('InitialTitle');
start.change('TitleA');
expect(document.title).toEqual('TitleA - InitialTitle');
start.reset();
expect(document.title).toEqual('InitialTitle');
describe('#start', () => {
it('throws if called before #setup', () => {
const docTitle = new DocTitleService();
expect(() => docTitle.start()).toThrowErrorMatchingInlineSnapshot(
`"DocTitleService#setup must be called before DocTitleService#start"`
);
});
describe('#change()', () => {
it('changes the title of the document', async () => {
getStart().change('TitleA');
expect(document.title).toEqual('TitleA - KibanaTest');
});
it('appends the baseTitle to the title', async () => {
const start = getStart('BaseTitle');
start.change('TitleA');
expect(document.title).toEqual('TitleA - BaseTitle');
start.change('TitleB');
expect(document.title).toEqual('TitleB - BaseTitle');
});
it('accepts string arrays as input', async () => {
const start = getStart();
start.change(['partA', 'partB']);
expect(document.title).toEqual(`partA - partB - ${defaultTitle}`);
start.change(['partA', 'partB', 'partC']);
expect(document.title).toEqual(`partA - partB - partC - ${defaultTitle}`);
});
});
describe('#reset()', () => {
it('resets the title to the initial value', async () => {
const start = getStart('InitialTitle');
start.change('TitleA');
expect(document.title).toEqual('TitleA - InitialTitle');
start.reset();
expect(document.title).toEqual('InitialTitle');
});
});
});
});

View file

@ -7,9 +7,14 @@
*/
import { compact, flattenDeep, isString } from 'lodash';
import { Observable, ReplaySubject, distinctUntilChanged } from 'rxjs';
import type { ChromeDocTitle } from '@kbn/core-chrome-browser';
interface StartDeps {
export interface InternalChromeDocTitleSetup {
title$: Observable<string>;
}
interface SetupDeps {
document: { title: string };
}
@ -18,12 +23,24 @@ const titleSeparator = ' - ';
/** @internal */
export class DocTitleService {
private document = { title: '' };
private baseTitle = '';
private document?: { title: string };
private baseTitle?: string;
private titleSubject = new ReplaySubject<string>(1);
public start({ document }: StartDeps): ChromeDocTitle {
public setup({ document }: SetupDeps): InternalChromeDocTitleSetup {
this.document = document;
this.baseTitle = document.title;
this.titleSubject.next(this.baseTitle);
return {
title$: this.titleSubject.asObservable().pipe(distinctUntilChanged()),
};
}
public start(): ChromeDocTitle {
if (this.document === undefined || this.baseTitle === undefined) {
throw new Error('DocTitleService#setup must be called before DocTitleService#start');
}
return {
change: (title: string | string[]) => {
@ -36,7 +53,9 @@ export class DocTitleService {
}
private applyTitle(title: string | string[]) {
this.document.title = this.render(title);
const rendered = this.render(title);
this.document!.title = rendered;
this.titleSubject.next(rendered);
}
private render(title: string | string[]) {

View file

@ -0,0 +1,39 @@
/*
* 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 { firstValueFrom, of, ReplaySubject } from 'rxjs';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
describe('registerAnalyticsContextProvider', () => {
let analytics: ReturnType<typeof analyticsServiceMock.createAnalyticsServiceSetup>;
beforeEach(() => {
analytics = analyticsServiceMock.createAnalyticsServiceSetup();
});
test('it registers the context provider', async () => {
registerAnalyticsContextProvider(analytics, of('some title'));
expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1);
expect(analytics.registerContextProvider).toHaveBeenCalledWith(
expect.objectContaining({
name: 'page title',
})
);
});
test('emits a context value when location$ emits', async () => {
const title$ = new ReplaySubject<string>(1);
registerAnalyticsContextProvider(analytics, title$);
title$.next('kibana title');
await expect(
firstValueFrom(analytics.registerContextProvider.mock.calls[0][0].context$)
).resolves.toEqual({ page_title: 'kibana title' });
});
});

View file

@ -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 { type Observable, map } from 'rxjs';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
/**
* Registers the Analytics context provider to enrich events with the page title.
* @param analytics Analytics service.
* @param pageTitle$ Observable emitting the page title.
* @private
*/
export function registerAnalyticsContextProvider(
analytics: AnalyticsServiceSetup,
pageTitle$: Observable<string>
) {
analytics.registerContextProvider({
name: 'page title',
context$: pageTitle$.pipe(
map((pageTitle) => ({
page_title: pageTitle,
}))
),
schema: {
page_title: { type: 'text', _meta: { description: 'The page title' } },
},
});
}

View file

@ -39,6 +39,8 @@
"@kbn/core-custom-branding-browser-mocks",
"@kbn/core-custom-branding-browser",
"@kbn/core-custom-branding-common",
"@kbn/core-analytics-browser-mocks",
"@kbn/core-analytics-browser",
],
"exclude": [
"target/**/*",

View file

@ -87,6 +87,7 @@ const createStartContractMock = () => {
type ChromeServiceContract = PublicMethodsOf<ChromeService>;
const createMock = () => {
const mocked: jest.Mocked<ChromeServiceContract> = {
setup: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
};

View file

@ -311,6 +311,11 @@ describe('#setup()', () => {
await setupCore();
expect(MockThemeService.setup).toHaveBeenCalledTimes(1);
});
it('calls chrome#setup()', async () => {
await setupCore();
expect(MockChromeService.setup).toHaveBeenCalledTimes(1);
});
});
describe('#start()', () => {

View file

@ -236,6 +236,7 @@ export class CoreSystem {
fatalErrors: this.fatalErrorsSetup,
executionContext,
});
this.chrome.setup({ analytics });
const uiSettings = this.uiSettings.setup({ http, injectedMetadata });
const settings = this.settings.setup({ http, injectedMetadata });
const notifications = this.notifications.setup({ uiSettings });

View file

@ -99,6 +99,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(event.context.viewport_height).to.be.a('number');
});
it('should have the properties provided by the "page title" context provider', async () => {
expect(event.context).to.have.property('page_title');
expect(event.context.page_title).to.be.a('string');
});
it('should have the properties provided by the "page url" context provider', () => {
expect(event.context).to.have.property('page_url');
expect(event.context.page_url).to.be.a('string');