/* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React from 'react'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; import { InjectedMetadataSetup } from '../injected_metadata'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; import { ContextSetup, IContextContainer } from '../context'; import { PluginOpaqueId } from '../plugins'; import { AppRouter } from './ui'; import { Capabilities, CapabilitiesService } from './capabilities'; import { App, AppBase, AppLeaveHandler, AppMount, AppMountDeprecated, AppNavLinkStatus, AppStatus, AppUpdatableFields, AppUpdater, InternalApplicationSetup, InternalApplicationStart, LegacyApp, LegacyAppMounter, Mounter, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils'; interface SetupDeps { context: ContextSetup; http: HttpSetup; injectedMetadata: InjectedMetadataSetup; history?: History<any>; /** Used to redirect to external urls (and legacy apps) */ redirectTo?: (path: string) => void; } interface StartDeps { http: HttpStart; overlays: OverlayStart; } // Mount functions with two arguments are assumed to expect deprecated `context` object. const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => mount.length === 2; function filterAvailable<T>(m: Map<string, T>, capabilities: Capabilities) { return new Map( [...m].filter( ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true ) ); } const findMounter = (mounters: Map<string, Mounter>, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); const getAppUrl = (mounters: Map<string, Mounter>, appId: string, path: string = '') => { const appBasePath = mounters.get(appId)?.appRoute ? `/${mounters.get(appId)!.appRoute}` : `/app/${appId}`; return appendAppPath(appBasePath, path); }; const allApplicationsFilter = '__ALL__'; interface AppUpdaterWrapper { application: string; updater: AppUpdater; } /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { private readonly apps = new Map<string, App<any> | LegacyApp>(); private readonly mounters = new Map<string, Mounter>(); private readonly capabilities = new CapabilitiesService(); private readonly appLeaveHandlers = new Map<string, AppLeaveHandler>(); private currentAppId$ = new BehaviorSubject<string | undefined>(undefined); private readonly statusUpdaters$ = new BehaviorSubject<Map<symbol, AppUpdaterWrapper>>(new Map()); private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); private registrationClosed = false; private history?: History<any>; private mountContext?: IContextContainer<AppMountDeprecated>; private navigate?: (url: string, state: any) => void; private redirectTo?: (url: string) => void; public setup({ context, http: { basePath }, injectedMetadata, redirectTo = (path: string) => { window.location.assign(path); }, history, }: SetupDeps): InternalApplicationSetup { const basename = basePath.get(); if (injectedMetadata.getLegacyMode()) { this.currentAppId$.next(injectedMetadata.getLegacyMetadata().app.id); } else { // Only setup history if we're not in legacy mode this.history = history || createBrowserHistory({ basename }); } // If we do not have history available, use redirectTo to do a full page refresh. this.navigate = (url, state) => // basePath not needed here because `history` is configured with basename this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); this.redirectTo = redirectTo; this.mountContext = context.createContextContainer(); const registerStatusUpdater = (application: string, updater$: Observable<AppUpdater>) => { const updaterId = Symbol(); const subscription = updater$.subscribe((updater) => { const nextValue = new Map(this.statusUpdaters$.getValue()); nextValue.set(updaterId, { application, updater, }); this.statusUpdaters$.next(nextValue); }); this.subscriptions.push(subscription); }; const wrapMount = (plugin: PluginOpaqueId, app: App<any>): AppMount => { let handler: AppMount; if (isAppMountDeprecated(app.mount)) { handler = this.mountContext!.createHandler(plugin, app.mount); if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn( `App [${app.id}] is using deprecated mount context. Use core.getStartServices() instead.` ); } } else { handler = app.mount; } return async (params) => { this.currentAppId$.next(app.id); return handler(params); }; }; return { registerMountContext: this.mountContext!.registerContext, register: (plugin, app: App<any>) => { app = { appRoute: `/app/${app.id}`, ...app }; if (this.registrationClosed) { throw new Error(`Applications cannot be registered after "setup"`); } else if (this.apps.has(app.id)) { throw new Error(`An application is already registered with the id "${app.id}"`); } else if (findMounter(this.mounters, app.appRoute)) { throw new Error( `An application is already registered with the appRoute "${app.appRoute}"` ); } else if (basename && app.appRoute!.startsWith(`${basename}/`)) { throw new Error('Cannot register an application route that includes HTTP base path'); } const { updater$, ...appProps } = app; this.apps.set(app.id, { ...appProps, status: app.status ?? AppStatus.accessible, navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, legacy: false, }); if (updater$) { registerStatusUpdater(app.id, updater$); } this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), mount: wrapMount(plugin, app), unmountBeforeMounting: false, legacy: false, }); }, registerLegacyApp: (app) => { const appRoute = `/app/${app.id.split(':')[0]}`; if (this.registrationClosed) { throw new Error('Applications cannot be registered after "setup"'); } else if (this.apps.has(app.id)) { throw new Error(`An application is already registered with the id "${app.id}"`); } else if (basename && appRoute!.startsWith(`${basename}/`)) { throw new Error('Cannot register an application route that includes HTTP base path'); } const appBasePath = basePath.prepend(appRoute); const mount: LegacyAppMounter = ({ history: appHistory }) => { redirectTo(appHistory.createHref(appHistory.location)); window.location.reload(); }; const { updater$, ...appProps } = app; this.apps.set(app.id, { ...appProps, status: app.status ?? AppStatus.accessible, navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, legacy: true, }); if (updater$) { registerStatusUpdater(app.id, updater$); } this.mounters.set(app.id, { appRoute, appBasePath, mount, unmountBeforeMounting: true, legacy: true, }); }, registerAppUpdater: (appUpdater$: Observable<AppUpdater>) => registerStatusUpdater(allApplicationsFilter, appUpdater$), }; } public async start({ http, overlays }: StartDeps): Promise<InternalApplicationStart> { if (!this.mountContext) { throw new Error('ApplicationService#setup() must be invoked before start.'); } const httpLoadingCount$ = new BehaviorSubject(0); http.addLoadingCountSource(httpLoadingCount$); this.registrationClosed = true; window.addEventListener('beforeunload', this.onBeforeUnload); const { capabilities } = await this.capabilities.start({ appIds: [...this.mounters.keys()], http, }); const availableMounters = filterAvailable(this.mounters, capabilities); const availableApps = filterAvailable(this.apps, capabilities); const applications$ = new BehaviorSubject(availableApps); this.statusUpdaters$ .pipe( map((statusUpdaters) => { return new Map( [...availableApps].map(([id, app]) => [ id, updateStatus(app, [...statusUpdaters.values()]), ]) ); }) ) .subscribe((apps) => applications$.next(apps)); const applicationStatuses$ = applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, app.status!]))), shareReplay(1) ); const navigateToApp: InternalApplicationStart['navigateToApp'] = async ( appId, { path, state }: { path?: string; state?: any } = {} ) => { if (await this.shouldNavigate(overlays)) { if (path === undefined) { path = applications$.value.get(appId)?.defaultPath; } this.appLeaveHandlers.delete(this.currentAppId$.value!); this.navigate!(getAppUrl(availableMounters, appId, path), state); this.currentAppId$.next(appId); } }; return { applications$: applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, getAppInfo(app)]))), shareReplay(1) ), capabilities, currentAppId$: this.currentAppId$.pipe( filter((appId) => appId !== undefined), distinctUntilChanged(), takeUntil(this.stop$) ), registerMountContext: this.mountContext.registerContext, getUrlForApp: ( appId, { path, absolute = false }: { path?: string; absolute?: boolean } = {} ) => { const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path)); return absolute ? relativeToAbsolute(relUrl) : relUrl; }, navigateToApp, navigateToUrl: async (url) => { const appInfo = parseAppUrl(url, http.basePath, this.apps); if (appInfo) { return navigateToApp(appInfo.app, { path: appInfo.path }); } else { return this.redirectTo!(url); } }, getComponent: () => { if (!this.history) { return null; } return ( <AppRouter history={this.history} mounters={availableMounters} appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); }, }; } private setAppLeaveHandler = (appId: string, handler: AppLeaveHandler) => { this.appLeaveHandlers.set(appId, handler); }; private async shouldNavigate(overlays: OverlayStart): Promise<boolean> { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { return true; } const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); if (isConfirmAction(action)) { const confirmed = await overlays.openConfirm(action.text, { title: action.title, 'data-test-subj': 'appLeaveConfirmModal', }); if (!confirmed) { return false; } } return true; } private onBeforeUnload = (event: Event) => { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { return; } const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); if (isConfirmAction(action)) { event.preventDefault(); // some browsers accept a string return value being the message displayed event.returnValue = action.text as any; } }; public stop() { this.stop$.next(); this.currentAppId$.complete(); this.statusUpdaters$.complete(); this.subscriptions.forEach((sub) => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); } } const updateStatus = <T extends AppBase>(app: T, statusUpdaters: AppUpdaterWrapper[]): T => { let changes: Partial<AppUpdatableFields> = {}; statusUpdaters.forEach((wrapper) => { if (wrapper.application !== allApplicationsFilter && wrapper.application !== app.id) { return; } const fields = wrapper.updater(app); if (fields) { changes = { ...changes, ...fields, // status and navLinkStatus enums are ordered by reversed priority // if multiple updaters wants to change these fields, we will always follow the priority order. status: Math.max(changes.status ?? 0, fields.status ?? 0), navLinkStatus: Math.max(changes.navLinkStatus ?? 0, fields.navLinkStatus ?? 0), }; } }); return { ...app, ...changes, }; };