mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
06a00d6b59
commit
27df64c2bc
12 changed files with 266 additions and 35 deletions
|
@ -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,
|
||||
};
|
||||
});
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
|
@ -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' } },
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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()', () => {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue