From 3f83a0862641c2dfc80e44355640eab445090110 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Tue, 16 Apr 2019 11:55:08 -0500 Subject: [PATCH] [wip] pull navlinks from app service --- .../application/application_service.tsx | 66 ++++++++++++++ .../capabilities/capabilities_service.tsx | 45 +++++++++- src/core/public/chrome/chrome_service.ts | 7 +- src/core/public/chrome/index.ts | 2 +- src/core/public/chrome/nav_links/index.ts | 2 +- src/core/public/chrome/nav_links/nav_link.ts | 14 +-- .../chrome/nav_links/nav_links_service.ts | 86 +++++++++++++++++-- src/core/public/core_system.ts | 10 ++- src/core/public/index.ts | 19 ++-- src/legacy/core_plugins/kibana/index.js | 14 +-- .../kibana/public/register_apps.ts | 34 ++++++++ .../header_global_nav/components/header.tsx | 55 ++++++------ 12 files changed, 283 insertions(+), 71 deletions(-) create mode 100644 src/legacy/core_plugins/kibana/public/register_apps.ts diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 04824f07064014e..3f096d9603d1cf5 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -17,8 +17,59 @@ * under the License. */ +import { Observable, BehaviorSubject, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + +interface BaseApp { + id: string; + + /** + * An ordinal used to sort nav links relative to one another for display. + */ + order: number; + + /** + * The title of the application. + */ + title: string; + + /** + * An observable for a tooltip shown when hovering over app link. + */ + tooltip$?: Observable; + + /** + * A EUI iconType that will be used for the app's icon. This icon + * takes precendence over the `icon` property. + */ + euiIconType?: string; + + /** + * A URL to an image file used as an icon. Used as a fallback + * if `euiIconType` is not provided. + */ + icon?: string; +} + +export interface App extends BaseApp { + rootRoute: string; + + mount(targetDomElement: HTMLElement): () => void; +} + +export interface LegacyApp extends BaseApp { + appUrl: string; + + url?: string; +} + export interface ApplicationServiceSetup { mount: (mountHandler: Function) => void; + registerApp(app: App): void; + registerLegacyApp(app: LegacyApp): void; + allApps$: Observable; + apps$: Observable; + legacyApps$: Observable; } /** @@ -27,8 +78,23 @@ export interface ApplicationServiceSetup { */ export class ApplicationService { public setup(): ApplicationServiceSetup { + const apps$ = new BehaviorSubject([]); + const legacyApps$ = new BehaviorSubject([]); + const allApps$ = combineLatest(apps$, legacyApps$).pipe( + map(([apps, legacyApps]) => [...apps, ...legacyApps]) + ); + return { + allApps$, + apps$, + legacyApps$, mount(mountHandler: Function) {}, + registerApp(app: App) { + apps$.next([...apps$.value, app]); + }, + registerLegacyApp(app: LegacyApp) { + legacyApps$.next([...legacyApps$.value, app]); + }, }; } diff --git a/src/core/public/capabilities/capabilities_service.tsx b/src/core/public/capabilities/capabilities_service.tsx index 560b8f628ee37f7..8f361bc2a86ac7d 100644 --- a/src/core/public/capabilities/capabilities_service.tsx +++ b/src/core/public/capabilities/capabilities_service.tsx @@ -16,10 +16,18 @@ * specific language governing permissions and limitations * under the License. */ + +import { debounceTime, map, concatMap } from 'rxjs/operators'; +import { Subject, from } from 'rxjs'; + import { InjectedMetadataSetup } from '../injected_metadata'; import { deepFreeze } from '../utils/deep_freeze'; +import { ApplicationServiceSetup } from '../application'; +import { ChromeSetup, ChromeNavLink } from '../chrome'; -interface StartDeps { +interface SetupDeps { + application: ApplicationServiceSetup; + chrome: ChromeSetup; injectedMetadata: InjectedMetadataSetup; } @@ -57,13 +65,46 @@ export interface CapabilitiesSetup { getCapabilities: () => Capabilities; } +function hasCapabilitiesForLink(caps: any, link: ChromeNavLink) { + // TODO: some logic to read the capabilities and see if link should be shown. + return true; +} + /** @internal */ /** * Service that is responsible for UI Capabilities. */ export class CapabilitiesService { - public setup({ injectedMetadata }: StartDeps): CapabilitiesSetup { + public setup({ application, chrome, injectedMetadata }: SetupDeps): CapabilitiesSetup { + // TODO: `any` should be the shape of the capabilities API response + const capabilities$ = new Subject(); + + application.allApps$ + .pipe( + debounceTime(500), + // TODO: need this endpoint to actually exist + concatMap(apps => + from( + (async function() { + const res = await fetch('/api/security/capabilities', { + body: JSON.stringify({ apps }), + }); + // do something with response + return true; + })() + ) + ) + ) + .subscribe(capabilities => { + capabilities$.next(capabilities); + }); + + // Register an Observable link filter for showing chrome nav links + chrome.navLinks.addLinkFilter( + capabilities$.pipe(map(caps => link => hasCapabilitiesForLink(caps, link))) + ); + return { getCapabilities: () => deepFreeze(injectedMetadata.getInjectedVar('uiCapabilities') as Capabilities), diff --git a/src/core/public/chrome/chrome_service.ts b/src/core/public/chrome/chrome_service.ts index 8051bff9cbef6a0..1d6b32368ac1441 100644 --- a/src/core/public/chrome/chrome_service.ts +++ b/src/core/public/chrome/chrome_service.ts @@ -26,6 +26,7 @@ import { BasePathSetup } from '../base_path'; import { InjectedMetadataSetup } from '../injected_metadata'; import { NotificationsSetup } from '../notifications'; import { NavLinksService } from './nav_links/nav_links_service'; +import { ApplicationServiceSetup } from '../application'; const IS_COLLAPSED_KEY = 'core.chrome.isCollapsed'; @@ -55,6 +56,7 @@ interface ConstructorParams { } interface SetupDeps { + application: ApplicationServiceSetup; basePath: BasePathSetup; injectedMetadata: InjectedMetadataSetup; notifications: NotificationsSetup; @@ -70,7 +72,7 @@ export class ChromeService { this.browserSupportsCsp = browserSupportsCsp; } - public setup({ basePath, injectedMetadata, notifications }: SetupDeps) { + public setup({ application, basePath, injectedMetadata, notifications }: SetupDeps) { const FORCE_HIDDEN = isEmbedParamInHash(); const brand$ = new Rx.BehaviorSubject({}); @@ -186,7 +188,8 @@ export class ChromeService { setHelpExtension: (helpExtension?: ChromeHelpExtension) => { helpExtension$.next(helpExtension); }, - navLinks: this.navLinks.setup(basePath), + + navLinks: this.navLinks.setup(application, basePath), }; } diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 190bf44e0687022..5476b8acf6362c2 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -24,4 +24,4 @@ export { ChromeBrand, ChromeHelpExtension, } from './chrome_service'; -export { ChromeNavLinkProperties } from './nav_links'; +export { ChromeNavLink } from './nav_links'; diff --git a/src/core/public/chrome/nav_links/index.ts b/src/core/public/chrome/nav_links/index.ts index 26d99cdab993f75..8060d5cab23ebff 100644 --- a/src/core/public/chrome/nav_links/index.ts +++ b/src/core/public/chrome/nav_links/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { ChromeNavLinkProperties } from './nav_link'; +export { ChromeNavLink } from './nav_link'; export { NavLinksService } from './nav_links_service'; diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index 2bdea47a0ebe2b2..2b6f3fdd9922d66 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -22,7 +22,7 @@ import { BasePathSetup } from '../../base_path'; /** * @public */ -export interface ChromeNavLinkProperties { +export interface ChromeNavLink { /** * A unique identifier for looking up links. */ @@ -64,7 +64,7 @@ export interface ChromeNavLinkProperties { /** * A tooltip shown when hovering over an app link. */ - tooltip: string; + tooltip?: string; /** * The base URL used to open the root of an application. @@ -94,14 +94,14 @@ export interface ChromeNavLinkProperties { } export type NavLinkUpdateableFields = Partial< - Pick + Pick >; -export class NavLink { +export class NavLinkWrapper { public readonly id: string; - public readonly properties: Readonly; + public readonly properties: Readonly; - constructor(properties: ChromeNavLinkProperties, private readonly basePath: BasePathSetup) { + constructor(properties: ChromeNavLink, private readonly basePath: BasePathSetup) { if (!properties || !properties.id) { throw new Error('`id` is required.'); } @@ -111,6 +111,6 @@ export class NavLink { } public update(newProps: NavLinkUpdateableFields) { - return new NavLink({ ...this.properties, ...newProps }, this.basePath); + return new NavLinkWrapper({ ...this.properties, ...newProps }, this.basePath); } } diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 74aac7d6954450a..5b6b4c1d8b5c98c 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -18,27 +18,86 @@ */ import { sortBy } from 'lodash'; -import { BehaviorSubject, ReplaySubject } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { + BehaviorSubject, + ReplaySubject, + Observable, + combineLatest, + from, + isObservable, +} from 'rxjs'; +import { map, takeUntil, concatMap } from 'rxjs/operators'; import { BasePathSetup } from '../../base_path'; -import { ChromeNavLinkProperties, NavLink, NavLinkUpdateableFields } from './nav_link'; +import { ChromeNavLink, NavLinkWrapper, NavLinkUpdateableFields } from './nav_link'; +import { ApplicationServiceSetup } from '../../application'; + +export type NavLinkFilter = (link: ChromeNavLink) => boolean; export class NavLinksService { private readonly stop$ = new ReplaySubject(1); - public setup(basePath: BasePathSetup) { - const navLinks$ = new BehaviorSubject>([]); + public setup(application: ApplicationServiceSetup, basePath: BasePathSetup) { + const navLinks$ = new BehaviorSubject>([]); + const linkFilters$ = new BehaviorSubject>>([ + from([() => true]), + ]); + + // Generate app nav links for all legacy apps + // TODO: add for non-legacy apps + const appNavLinks$: Observable = application.legacyApps$.pipe( + map(apps => + apps.map( + app => + new NavLinkWrapper( + { + ...app, + active: false, + disabled: false, + hidden: false, + }, + basePath + ) + ) + ) + ); + + // Combine nav links from ApplicationService and manual nav links + // TODO: remove once manual nav links are gone. + const allNavLinks$: Observable = combineLatest(navLinks$, appNavLinks$).pipe( + map(([navLinks, appNavLinks]) => [...navLinks, ...appNavLinks]) + ); + + // Unwrap the filter observables + const latestFilters$: Observable = linkFilters$.pipe( + concatMap(filters => combineLatest(...filters)) + ); + // Filter nav links. Each link must pass every filter to be included. + const filteredNavLinks$: Observable = combineLatest( + allNavLinks$, + latestFilters$ + ).pipe( + map(([navLinks, filters]) => + navLinks.filter(link => + filters.reduce( + (passed, filter) => { + return passed && filter(link.properties); + }, + true as boolean + ) + ) + ) + ); return { getNavLinks$: () => { - return navLinks$.pipe( + return filteredNavLinks$.pipe( map(sortNavLinks), takeUntil(this.stop$) ); }, - add(navLink: ChromeNavLinkProperties) { - navLinks$.next([...navLinks$.value, new NavLink(navLink, basePath)]); + add(navLink: ChromeNavLink) { + navLinks$.next([...navLinks$.value, new NavLinkWrapper(navLink, basePath)]); }, clear() { @@ -62,6 +121,15 @@ export class NavLinksService { navLinks$.next(navLinks$.value.filter(link => link.id === id)); }, + /** + * Add a filter to the links displayed in the global navigation. + * @param filter A filter function or an Observable that emits filter functions. + */ + addLinkFilter(filter: NavLinkFilter | Observable) { + const filter$ = isObservable(filter) ? filter : from([filter]); + linkFilters$.next([...linkFilters$.value, filter$]); + }, + update(id: string, values: NavLinkUpdateableFields) { if (!this.exists(id)) { return; @@ -83,6 +151,6 @@ export class NavLinksService { } } -function sortNavLinks(navLinks: ReadonlyArray) { +function sortNavLinks(navLinks: ReadonlyArray) { return sortBy(navLinks.map(link => link.properties), 'order'); } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 90f75e05f8c5f79..076aae74528db22 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -19,7 +19,7 @@ import './core.css'; -import { Subject } from 'rxjs'; +import { Subject, from } from 'rxjs'; import { CoreSetup } from '.'; import { BasePathService } from './base_path'; @@ -34,6 +34,7 @@ import { NotificationsService } from './notifications'; import { OverlayService } from './overlays'; import { PluginsService } from './plugins'; import { UiSettingsService } from './ui_settings'; +import { ApplicationService } from './application'; interface Params { rootDomElement: HTMLElement; @@ -68,6 +69,7 @@ export class CoreSystem { private readonly capabilities: CapabilitiesService; private readonly overlay: OverlayService; private readonly plugins: PluginsService; + private readonly application: ApplicationService; private readonly rootDomElement: HTMLElement; private readonly notificationsTargetDomElement$: Subject; @@ -110,6 +112,7 @@ export class CoreSystem { this.uiSettings = new UiSettingsService(); this.overlayTargetDomElement = document.createElement('div'); this.overlay = new OverlayService(this.overlayTargetDomElement); + this.application = new ApplicationService(); this.chrome = new ChromeService({ browserSupportsCsp }); const core: CoreContext = {}; @@ -132,20 +135,23 @@ export class CoreSystem { const http = this.http.setup({ fatalErrors }); const overlays = this.overlay.setup({ i18n }); const basePath = this.basePath.setup({ injectedMetadata }); - const capabilities = this.capabilities.setup({ injectedMetadata }); const uiSettings = this.uiSettings.setup({ notifications, http, injectedMetadata, basePath, }); + const application = this.application.setup(); const chrome = this.chrome.setup({ + application, injectedMetadata, notifications, basePath, }); + const capabilities = this.capabilities.setup({ application, chrome, injectedMetadata }); const core: CoreSetup = { + application, basePath, chrome, fatalErrors, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index efa33d0508b37e1..f76ce4218e45401 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -23,7 +23,7 @@ import { ChromeBrand, ChromeBreadcrumb, ChromeHelpExtension, - ChromeNavLinkProperties, + ChromeNavLink, ChromeSetup, } from './chrome'; import { FatalErrorsSetup } from './fatal_errors'; @@ -34,6 +34,7 @@ import { NotificationsSetup, Toast, ToastInput, ToastsSetup } from './notificati import { FlyoutRef, OverlaySetup } from './overlays'; import { Plugin, PluginInitializer, PluginInitializerContext, PluginSetupContext } from './plugins'; import { UiSettingsClient, UiSettingsSetup, UiSettingsState } from './ui_settings'; +import { ApplicationServiceSetup } from './application'; export { CoreContext, CoreSystem } from './core_system'; @@ -43,19 +44,21 @@ export { CoreContext, CoreSystem } from './core_system'; * @public */ export interface CoreSetup { - i18n: I18nSetup; - injectedMetadata: InjectedMetadataSetup; - fatalErrors: FatalErrorsSetup; - notifications: NotificationsSetup; - http: HttpSetup; + application: ApplicationServiceSetup; basePath: BasePathSetup; capabilities: CapabilitiesSetup; - uiSettings: UiSettingsSetup; chrome: ChromeSetup; + fatalErrors: FatalErrorsSetup; + http: HttpSetup; + i18n: I18nSetup; + injectedMetadata: InjectedMetadataSetup; + notifications: NotificationsSetup; overlays: OverlaySetup; + uiSettings: UiSettingsSetup; } export { + ApplicationServiceSetup, BasePathSetup, HttpSetup, FatalErrorsSetup, @@ -66,7 +69,7 @@ export { ChromeBreadcrumb, ChromeBrand, ChromeHelpExtension, - ChromeNavLinkProperties, + ChromeNavLink, InjectedMetadataSetup, InjectedMetadataParams, Plugin, diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index af78119c046ac63..83486bbacbe9916 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -58,7 +58,10 @@ export default function (kibana) { }, uiExports: { - hacks: ['plugins/kibana/dev_tools/hacks/hide_empty_tools'], + hacks: [ + 'plugins/kibana/dev_tools/hacks/hide_empty_tools', + 'plugins/kibana/register_apps' + ], fieldFormats: ['plugins/kibana/field_formats/register'], savedObjectTypes: [ 'plugins/kibana/visualize/saved_visualizations/saved_visualization_register', @@ -74,15 +77,6 @@ export default function (kibana) { styleSheetPaths: resolve(__dirname, 'public/index.scss'), links: [ { - id: 'kibana:discover', - title: i18n.translate('kbn.discoverTitle', { - defaultMessage: 'Discover' - }), - order: -1003, - url: `${kbnBaseUrl}#/discover`, - icon: 'plugins/kibana/assets/discover.svg', - euiIconType: 'discoverApp', - }, { id: 'kibana:visualize', title: i18n.translate('kbn.visualizeTitle', { defaultMessage: 'Visualize' diff --git a/src/legacy/core_plugins/kibana/public/register_apps.ts b/src/legacy/core_plugins/kibana/public/register_apps.ts new file mode 100644 index 000000000000000..07367f03a44d3a9 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/register_apps.ts @@ -0,0 +1,34 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { getNewPlatform } from 'ui/new_platform'; + +const kbnBaseUrl = '/app/kibana'; +const appService = getNewPlatform().setup.core.application; + +appService.registerLegacyApp({ + id: 'kibana:discover', + title: i18n.translate('kbn.discoverTitle', { + defaultMessage: 'Discover', + }), + order: -1003, + appUrl: `${kbnBaseUrl}#/discover`, + euiIconType: 'discoverApp', +}); diff --git a/src/legacy/ui/public/chrome/directives/header_global_nav/components/header.tsx b/src/legacy/ui/public/chrome/directives/header_global_nav/components/header.tsx index 76a56d95b5d1b76..1138a5e24ed18a3 100644 --- a/src/legacy/ui/public/chrome/directives/header_global_nav/components/header.tsx +++ b/src/legacy/ui/public/chrome/directives/header_global_nav/components/header.tsx @@ -63,14 +63,14 @@ import { HeaderHelpMenu } from './header_help_menu'; import { HeaderNavControls } from './header_nav_controls'; import { NavControlSide } from '../'; -import { ChromeBreadcrumb, ChromeNavLinkProperties } from '../../../../../../../core/public'; +import { ChromeBreadcrumb, ChromeNavLink } from '../../../../../../../core/public'; interface Props { appTitle?: string; breadcrumbs$: Rx.Observable; homeHref: string; isVisible: boolean; - navLinks$: Rx.Observable; + navLinks$: Rx.Observable; recentlyAccessed$: Rx.Observable; forceAppSwitcherNavigation$: Rx.Observable; helpExtension$: Rx.Observable; @@ -85,7 +85,7 @@ const TRUNCATE_LIMIT: number = 64; const TRUNCATE_AT: number = 58; function extendRecentlyAccessedHistoryItem( - navLinks: ChromeNavLinkProperties[], + navLinks: ChromeNavLink[], recentlyAccessed: RecentlyAccessedHistoryItem ) { const href = chrome.addBasePath(recentlyAccessed.link); @@ -111,7 +111,7 @@ function extendRecentlyAccessedHistoryItem( }; } -function extendNavLink(navLink: ChromeNavLinkProperties) { +function extendNavLink(navLink: ChromeNavLink) { return { ...navLink, href: navLink.url && !navLink.active ? navLink.url : navLink.appUrl, @@ -231,31 +231,28 @@ class HeaderUI extends Component { const leftNavControls = navControls.bySide[NavControlSide.Left]; const rightNavControls = navControls.bySide[NavControlSide.Right]; - let navLinksArray = navLinks.map(navLink => - navLink.hidden || !uiCapabilities.navLinks[navLink.id] - ? null - : { - key: navLink.id, - label: navLink.title, - href: navLink.href, - iconType: navLink.euiIconType, - icon: - !navLink.euiIconType && navLink.icon ? ( - - ) : ( - undefined - ), - isActive: navLink.active, - 'data-test-subj': 'navDrawerAppsMenuLink', - } - ); - // filter out the null items - navLinksArray = navLinksArray.filter(item => item !== null); + const navLinksArray = navLinks + // TODO: remove hidden filter? + .filter(navLink => !navLink.hidden) + .map(navLink => ({ + key: navLink.id, + label: navLink.title, + href: navLink.href, + iconType: navLink.euiIconType, + icon: + !navLink.euiIconType && navLink.icon ? ( + + ) : ( + undefined + ), + isActive: navLink.active, + 'data-test-subj': 'navDrawerAppsMenuLink', + })); const recentLinksArray = [ {