diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md index fe95cb38cd97c..e30e8262f40b2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md @@ -4,7 +4,7 @@ ## ChromeNavLink.euiIconType property -A EUI iconType that will be used for the app's icon. This icon takes precendence over the `icon` property. +A EUI iconType that will be used for the app's icon. This icon takes precedence over the `icon` property. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.href.md new file mode 100644 index 0000000000000..a8af0c997ca78 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeNavLink](./kibana-plugin-core-public.chromenavlink.md) > [href](./kibana-plugin-core-public.chromenavlink.href.md) + +## ChromeNavLink.href property + +Settled state between `url`, `baseUrl`, and `active` + +Signature: + +```typescript +readonly href?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md index a9fabb38df869..0349e865bff97 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md @@ -20,8 +20,9 @@ export interface ChromeNavLink | [category](./kibana-plugin-core-public.chromenavlink.category.md) | AppCategory | The category the app lives in | | [disabled](./kibana-plugin-core-public.chromenavlink.disabled.md) | boolean | Disables a link from being clickable. | | [disableSubUrlTracking](./kibana-plugin-core-public.chromenavlink.disablesuburltracking.md) | boolean | A flag that tells legacy chrome to ignore the link when tracking sub-urls | -| [euiIconType](./kibana-plugin-core-public.chromenavlink.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | +| [euiIconType](./kibana-plugin-core-public.chromenavlink.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precedence over the icon property. | | [hidden](./kibana-plugin-core-public.chromenavlink.hidden.md) | boolean | Hides a link from the navigation. | +| [href](./kibana-plugin-core-public.chromenavlink.href.md) | string | Settled state between url, baseUrl, and active | | [icon](./kibana-plugin-core-public.chromenavlink.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | | [id](./kibana-plugin-core-public.chromenavlink.id.md) | string | A unique identifier for looking up links. | | [linkToLastSubUrl](./kibana-plugin-core-public.chromenavlink.linktolastsuburl.md) | boolean | Whether or not the subUrl feature should be enabled. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md index 7f6dc7e0d5640..bd5a1399cded7 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type ChromeNavLinkUpdateableFields = Partial>; +export declare type ChromeNavLinkUpdateableFields = Partial>; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md index 58690300b3bd6..85eb4825bc2e3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md @@ -7,5 +7,5 @@ Signature: ```typescript -QueryStringInput: React.FC> +QueryStringInput: React.FC> ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index b015ebfcbaada..fc141b8c89c18 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index fc7e78f209022..67cd43f0647e4 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -36,7 +36,7 @@ import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; -import { Header, LoadingIndicator } from './ui'; +import { Header } from './ui'; import { NavType } from './ui/header'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; @@ -214,31 +214,29 @@ export class ChromeService { docTitle, getHeaderComponent: () => ( - - -
- +
), setAppTitle: (appTitle: string) => appTitle$.next(appTitle), diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index fb2972735c2b7..55b5c80526bab 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -62,7 +62,7 @@ export interface ChromeNavLink { /** * A EUI iconType that will be used for the app's icon. This icon - * takes precendence over the `icon` property. + * takes precedence over the `icon` property. */ readonly euiIconType?: string; @@ -72,6 +72,14 @@ export interface ChromeNavLink { */ readonly icon?: string; + /** + * Settled state between `url`, `baseUrl`, and `active` + * + * @internalRemarks + * This should be required once legacy apps are gone. + */ + readonly href?: string; + /** LEGACY FIELDS */ /** @@ -144,7 +152,7 @@ export interface ChromeNavLink { /** @public */ export type ChromeNavLinkUpdateableFields = Partial< - Pick + Pick >; export class NavLinkWrapper { @@ -162,7 +170,7 @@ export class NavLinkWrapper { public update(newProps: ChromeNavLinkUpdateableFields) { // Enforce limited properties at runtime for JS code - newProps = pick(newProps, ['active', 'disabled', 'hidden', 'url', 'subUrlBase']); + newProps = pick(newProps, ['active', 'disabled', 'hidden', 'url', 'subUrlBase', 'href']); return new NavLinkWrapper({ ...this.properties, ...newProps }); } } diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts index f79b1df77f8e1..24744fe53c82c 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -24,7 +24,12 @@ import { appendAppPath } from '../../application/utils'; export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; - const baseUrl = isLegacyApp(app) ? basePath.prepend(app.appUrl) : basePath.prepend(app.appRoute!); + const relativeBaseUrl = isLegacyApp(app) + ? basePath.prepend(app.appUrl) + : basePath.prepend(app.appRoute!); + const url = relativeToAbsolute(appendAppPath(relativeBaseUrl, app.defaultPath)); + const baseUrl = relativeToAbsolute(relativeBaseUrl); + return new NavLinkWrapper({ ...app, hidden: useAppStatus @@ -32,17 +37,27 @@ export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWra : app.navLinkStatus === AppNavLinkStatus.hidden, disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, legacy: isLegacyApp(app), - baseUrl: relativeToAbsolute(baseUrl), + baseUrl, ...(isLegacyApp(app) - ? {} + ? { + href: url && !url.startsWith(app.subUrlBase!) ? url : baseUrl, + } : { - url: relativeToAbsolute(appendAppPath(baseUrl, app.defaultPath)), + href: url, + url, }), }); } -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls +/** + * @param {string} url - a relative or root relative url. If a relative path is given then the + * absolute url returned will depend on the current page where this function is called from. For example + * if you are on page "http://www.mysite.com/shopping/kids" and you pass this function "adults", you would get + * back "http://www.mysite.com/shopping/adults". If you passed this function a root relative path, or one that + * starts with a "/", for example "/account/cart", you would get back "http://www.mysite.com/account/cart". + * @return {string} the relative url transformed into an absolute url + */ +export function relativeToAbsolute(url: string) { const a = document.createElement('a'); a.setAttribute('href', url); return a.href; diff --git a/src/core/public/chrome/recently_accessed/recently_accessed_service.ts b/src/core/public/chrome/recently_accessed/recently_accessed_service.ts index 27dbc288d18cb..86c7f3a1ef765 100644 --- a/src/core/public/chrome/recently_accessed/recently_accessed_service.ts +++ b/src/core/public/chrome/recently_accessed/recently_accessed_service.ts @@ -76,7 +76,7 @@ export interface ChromeRecentlyAccessed { * * @param link a relative URL to the resource (not including the {@link HttpStart.basePath | `http.basePath`}) * @param label the label to display in the UI - * @param id a unique string used to de-duplicate the recently accessed llist. + * @param id a unique string used to de-duplicate the recently accessed list. */ add(link: string, label: string, id: string): void; diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 866ea5f45d986..f5b17f8d214e9 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -2,140 +2,295 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
@@ -376,7 +531,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-label="recent 2" class="euiListGroupItem__button" data-test-subj="collapsibleNavAppLink--recent" - href="recent 2" + href="http://localhost/recent%202" rel="noreferrer" title="recent 2" > @@ -465,7 +620,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` style="max-width: none;" >
  • @@ -1023,7 +1179,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-label="recent 2" class="euiListGroupItem__button" data-test-subj="collapsibleNavAppLink--recent" - href="recent 2" + href="http://localhost/recent%202" rel="noreferrer" title="recent 2" > @@ -1112,7 +1268,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` style="max-width: none;" >
  • + +
    +
    +
    + +
  • +`; + +exports[`Header renders 2`] = ` +
    + +
    +
    +
    + +
    + +
    + +
    + +
    + + + +
    +
    + +
    + + + + + + + + + + +
    + +
    + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + +
    +
    +`; + +exports[`Header renders 3`] = ` +
    + +
    +
    +
    + +
    + +
    + +
    + +
    + + + +
    +
    + +
    + + + + +
    + + + + +
    + + +
    + + + + + + + + + + +
    + +
    + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + +
    + +
    +
    +
    + + +
    + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + > + + +
    + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + /> + + +
    +
    + +
    + + + + + +
    +
    +`; + +exports[`Header renders 4`] = ` +
    + +
    +
    +
    + +
    + +
    + +
    + + +
    + + + +
    +
    +
    + +
    + + + + +
    + + + + +
    + + +
    + + + + + + + + + + +
    + +
    + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + +
    +
    +`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap index d089019915686..fdaa17c279a10 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap @@ -5,6 +5,7 @@ exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 1`] = aria-current="page" className="euiBreadcrumb euiBreadcrumb--last" data-test-subj="breadcrumb first last" + title="First" > First @@ -39,6 +40,7 @@ Array [ aria-current="page" className="euiBreadcrumb euiBreadcrumb--last" data-test-subj="breadcrumb last" + title="Second" > Second , diff --git a/src/core/public/chrome/ui/header/_index.scss b/src/core/public/chrome/ui/header/_index.scss index 1b0438d748ff0..5c5e7f18b60a4 100644 --- a/src/core/public/chrome/ui/header/_index.scss +++ b/src/core/public/chrome/ui/header/_index.scss @@ -1,5 +1,3 @@ -@import './collapsible_nav'; - // TODO #64541 // Delete this block .chrHeaderWrapper:not(.headerWrapper) { diff --git a/src/core/public/chrome/ui/header/_collapsible_nav.scss b/src/core/public/chrome/ui/header/collapsible_nav.scss similarity index 100% rename from src/core/public/chrome/ui/header/_collapsible_nav.scss rename to src/core/public/chrome/ui/header/collapsible_nav.scss diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 527f0df598c7c..5a734d55445a2 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -19,11 +19,13 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; +import { BehaviorSubject } from 'rxjs'; import sinon from 'sinon'; -import { CollapsibleNav } from './collapsible_nav'; -import { DEFAULT_APP_CATEGORIES } from '../../..'; import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; -import { NavLink, RecentNavLink } from './nav_link'; +import { ChromeNavLink, DEFAULT_APP_CATEGORIES } from '../../..'; +import { httpServiceMock } from '../../../http/http_service.mock'; +import { ChromeRecentlyAccessedHistoryItem } from '../../recently_accessed'; +import { CollapsibleNav } from './collapsible_nav'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -31,40 +33,42 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES; -function mockLink({ label = 'discover', category, onClick }: Partial) { +function mockLink({ title = 'discover', category }: Partial) { return { - key: label, - label, - href: label, - isActive: true, - onClick: onClick || (() => {}), + title, category, - 'data-test-subj': label, + id: title, + href: title, + baseUrl: '/', + legacy: false, + isActive: true, + 'data-test-subj': title, }; } -function mockRecentNavLink({ label = 'recent', onClick }: Partial) { +function mockRecentNavLink({ label = 'recent' }: Partial) { return { - href: label, label, - title: label, - 'aria-label': label, - onClick, + link: label, + id: label, }; } function mockProps() { return { - id: 'collapsible-nav', - homeHref: '/', + appId$: new BehaviorSubject('test'), + basePath: httpServiceMock.createSetupContract({ basePath: '/test' }).basePath, + id: 'collapsibe-nav', isLocked: false, isOpen: false, - navLinks: [], - recentNavLinks: [], + homeHref: '/', + legacyMode: false, + navLinks$: new BehaviorSubject([]), + recentlyAccessed$: new BehaviorSubject([]), storage: new StubBrowserStorage(), - onIsOpenUpdate: () => {}, onIsLockedUpdate: () => {}, - navigateToApp: () => {}, + closeNav: () => {}, + navigateToApp: () => Promise.resolve(), }; } @@ -103,14 +107,14 @@ describe('CollapsibleNav', () => { it('renders links grouped by category', () => { // just a test of category functionality, categories are not accurate const navLinks = [ - mockLink({ label: 'discover', category: kibana }), - mockLink({ label: 'siem', category: security }), - mockLink({ label: 'metrics', category: observability }), - mockLink({ label: 'monitoring', category: management }), - mockLink({ label: 'visualize', category: kibana }), - mockLink({ label: 'dashboard', category: kibana }), - mockLink({ label: 'canvas' }), // links should be able to be rendered top level as well - mockLink({ label: 'logs', category: observability }), + mockLink({ title: 'discover', category: kibana }), + mockLink({ title: 'siem', category: security }), + mockLink({ title: 'metrics', category: observability }), + mockLink({ title: 'monitoring', category: management }), + mockLink({ title: 'visualize', category: kibana }), + mockLink({ title: 'dashboard', category: kibana }), + mockLink({ title: 'canvas' }), // links should be able to be rendered top level as well + mockLink({ title: 'logs', category: observability }), ]; const recentNavLinks = [ mockRecentNavLink({ label: 'recent 1' }), @@ -120,8 +124,8 @@ describe('CollapsibleNav', () => { ); expect(component).toMatchSnapshot(); @@ -134,8 +138,8 @@ describe('CollapsibleNav', () => { ); expectShownNavLinksCount(component, 3); @@ -149,32 +153,34 @@ describe('CollapsibleNav', () => { }); it('closes the nav after clicking a link', () => { - const onClick = sinon.spy(); - const onIsOpenUpdate = sinon.spy(); - const navLinks = [mockLink({ category: kibana, onClick })]; - const recentNavLinks = [mockRecentNavLink({ onClick })]; + const onClose = sinon.spy(); + const navLinks = [mockLink({ category: kibana }), mockLink({ title: 'categoryless' })]; + const recentNavLinks = [mockRecentNavLink({})]; const component = mount( ); component.setProps({ - onIsOpenUpdate: (isOpen: boolean) => { - component.setProps({ isOpen }); - onIsOpenUpdate(); + closeNav: () => { + component.setProps({ isOpen: false }); + onClose(); }, }); component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); - expect(onClick.callCount).toEqual(1); - expect(onIsOpenUpdate.callCount).toEqual(1); + expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); component.setProps({ isOpen: true }); component.find('[data-test-subj="collapsibleNavGroup-kibana"] a').simulate('click'); - expect(onClick.callCount).toEqual(2); - expect(onIsOpenUpdate.callCount).toEqual(2); + expect(onClose.callCount).toEqual(2); + expectNavIsClosed(component); + component.setProps({ isOpen: true }); + component.find('[data-test-subj="collapsibleNavGroup-noCategory"] a').simulate('click'); + expect(onClose.callCount).toEqual(3); + expectNavIsClosed(component); }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 8bca42db23517..9494e22920de8 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -17,6 +17,7 @@ * under the License. */ +import './collapsible_nav.scss'; import { EuiCollapsibleNav, EuiCollapsibleNavGroup, @@ -30,11 +31,16 @@ import { import { i18n } from '@kbn/i18n'; import { groupBy, sortBy } from 'lodash'; import React, { useRef } from 'react'; +import { useObservable } from 'react-use'; +import * as Rx from 'rxjs'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; +import { InternalApplicationStart } from '../../../application/types'; +import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { NavLink, RecentNavLink } from './nav_link'; +import { createEuiListItem, createRecentNavLink } from './nav_link'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -45,7 +51,7 @@ function getAllCategories(allCategorizedLinks: Record) { } function getOrderedCategories( - mainCategories: Record, + mainCategories: Record, categoryDictionary: ReturnType ) { return sortBy( @@ -69,35 +75,53 @@ function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { } interface Props { + appId$: InternalApplicationStart['currentAppId$']; + basePath: HttpStart['basePath']; + id: string; isLocked: boolean; isOpen: boolean; - navLinks: NavLink[]; - recentNavLinks: RecentNavLink[]; homeHref: string; - id: string; + legacyMode: boolean; + navLinks$: Rx.Observable; + recentlyAccessed$: Rx.Observable; storage?: Storage; onIsLockedUpdate: OnIsLockedUpdate; - onIsOpenUpdate: (isOpen?: boolean) => void; - navigateToApp: (appId: string) => void; + closeNav: () => void; + navigateToApp: InternalApplicationStart['navigateToApp']; } export function CollapsibleNav({ + basePath, + id, isLocked, isOpen, - navLinks, - recentNavLinks, - onIsLockedUpdate, - onIsOpenUpdate, homeHref, - id, - navigateToApp, + legacyMode, storage = window.localStorage, + onIsLockedUpdate, + closeNav, + navigateToApp, + ...observables }: Props) { + const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); + const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); + const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + return createEuiListItem({ + link, + legacyMode, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + onClick: closeNav, + ...(needsIcon && { basePath }), + }); + }; return ( {/* Pinned items */} @@ -127,7 +151,7 @@ export function CollapsibleNav({ iconType: 'home', href: homeHref, onClick: (event: React.MouseEvent) => { - onIsOpenUpdate(false); + closeNav(); if ( event.isDefaultPrevented() || event.altKey || @@ -159,21 +183,22 @@ export function CollapsibleNav({ onToggle={(isCategoryOpen) => setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} data-test-subj="collapsibleNavGroup-recentlyViewed" > - {recentNavLinks.length > 0 ? ( + {recentlyAccessed.length > 0 ? ( {}, ...link }) => ({ - 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: (e: React.MouseEvent) => { - onIsOpenUpdate(false); - onClick(e); - }, - ...link, - }))} + listItems={recentlyAccessed.map((link) => { + // TODO #64541 + // Can remove icon from recent links completely + const { iconType, ...hydratedLink } = createRecentNavLink(link, navLinks, basePath); + + return { + ...hydratedLink, + 'data-test-subj': 'collapsibleNavAppLink--recent', + onClick: closeNav, + }; + })} maxWidth="none" color="subdued" gutterSize="none" @@ -195,21 +220,8 @@ export function CollapsibleNav({ {/* Kibana, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName, i) => { + {orderedCategories.map((categoryName) => { const category = categoryDictionary[categoryName]!; - const links = allCategorizedLinks[categoryName].map( - ({ label, href, isActive, isDisabled, onClick }) => ({ - label, - href, - isActive, - isDisabled, - 'data-test-subj': 'collapsibleNavAppLink', - onClick: (e: React.MouseEvent) => { - onIsOpenUpdate(false); - onClick(e); - }, - }) - ); return ( readyForEUI(link))} maxWidth="none" color="subdued" gutterSize="none" @@ -237,23 +249,10 @@ export function CollapsibleNav({ })} {/* Things with no category (largely for custom plugins) */} - {unknowns.map(({ label, href, icon, isActive, isDisabled, onClick }, i) => ( - + {unknowns.map((link, i) => ( + - ) => { - onIsOpenUpdate(false); - onClick(e); - }} - /> + ))} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx new file mode 100644 index 0000000000000..13e1f6f086ae2 --- /dev/null +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { NavType } from '.'; +import { httpServiceMock } from '../../../http/http_service.mock'; +import { applicationServiceMock } from '../../../mocks'; +import { Header } from './header'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'mockId', +})); + +function mockProps() { + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + const application = applicationServiceMock.createInternalStartContract(); + + return { + application, + kibanaVersion: '1.0.0', + appTitle$: new BehaviorSubject('test'), + badge$: new BehaviorSubject(undefined), + breadcrumbs$: new BehaviorSubject([]), + homeHref: '/', + isVisible$: new BehaviorSubject(true), + kibanaDocLink: '/docs', + navLinks$: new BehaviorSubject([]), + recentlyAccessed$: new BehaviorSubject([]), + forceAppSwitcherNavigation$: new BehaviorSubject(false), + helpExtension$: new BehaviorSubject(undefined), + helpSupportUrl$: new BehaviorSubject(''), + legacyMode: false, + navControlsLeft$: new BehaviorSubject([]), + navControlsRight$: new BehaviorSubject([]), + basePath: http.basePath, + isLocked$: new BehaviorSubject(false), + navType$: new BehaviorSubject('modern' as NavType), + loadingCount$: new BehaviorSubject(0), + onIsLockedUpdate: () => {}, + }; +} + +describe('Header', () => { + beforeAll(() => { + Object.defineProperty(window, 'localStorage', { + value: new StubBrowserStorage(), + }); + }); + + it('renders', () => { + const isVisible$ = new BehaviorSubject(false); + const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }]); + const isLocked$ = new BehaviorSubject(false); + const navType$ = new BehaviorSubject('modern' as NavType); + const navLinks$ = new BehaviorSubject([ + { id: 'kibana', title: 'kibana', baseUrl: '', legacy: false }, + ]); + const recentlyAccessed$ = new BehaviorSubject([ + { link: '', label: 'dashboard', id: 'dashboard' }, + ]); + const component = mountWithIntl( +
    + ); + expect(component).toMatchSnapshot(); + + act(() => isVisible$.next(true)); + component.update(); + expect(component).toMatchSnapshot(); + + act(() => isLocked$.next(true)); + component.update(); + expect(component).toMatchSnapshot(); + + act(() => navType$.next('legacy' as NavType)); + component.update(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 6280d68587355..d24b342e0386b 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -28,9 +28,11 @@ import { htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Component, createRef } from 'react'; import classnames from 'classnames'; -import * as Rx from 'rxjs'; +import React, { createRef, useState } from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import { LoadingIndicator } from '../'; import { ChromeBadge, ChromeBreadcrumb, @@ -41,192 +43,101 @@ import { import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; -import { HeaderBadge } from './header_badge'; import { NavType, OnIsLockedUpdate } from './'; +import { CollapsibleNav } from './collapsible_nav'; +import { HeaderBadge } from './header_badge'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; import { HeaderHelpMenu } from './header_help_menu'; -import { HeaderNavControls } from './header_nav_controls'; -import { createNavLink, createRecentNavLink } from './nav_link'; import { HeaderLogo } from './header_logo'; +import { HeaderNavControls } from './header_nav_controls'; import { NavDrawer } from './nav_drawer'; -import { CollapsibleNav } from './collapsible_nav'; export interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; - appTitle$: Rx.Observable; - badge$: Rx.Observable; - breadcrumbs$: Rx.Observable; + appTitle$: Observable; + badge$: Observable; + breadcrumbs$: Observable; homeHref: string; - isVisible$: Rx.Observable; + isVisible$: Observable; kibanaDocLink: string; - navLinks$: Rx.Observable; - recentlyAccessed$: Rx.Observable; - forceAppSwitcherNavigation$: Rx.Observable; - helpExtension$: Rx.Observable; - helpSupportUrl$: Rx.Observable; + navLinks$: Observable; + recentlyAccessed$: Observable; + forceAppSwitcherNavigation$: Observable; + helpExtension$: Observable; + helpSupportUrl$: Observable; legacyMode: boolean; - navControlsLeft$: Rx.Observable; - navControlsRight$: Rx.Observable; + navControlsLeft$: Observable; + navControlsRight$: Observable; basePath: HttpStart['basePath']; - isLocked$: Rx.Observable; - navType$: Rx.Observable; + isLocked$: Observable; + navType$: Observable; + loadingCount$: ReturnType; onIsLockedUpdate: OnIsLockedUpdate; } -interface State { - appTitle: string; - isVisible: boolean; - navLinks: ChromeNavLink[]; - recentlyAccessed: ChromeRecentlyAccessedHistoryItem[]; - forceNavigation: boolean; - navControlsLeft: readonly ChromeNavControl[]; - navControlsRight: readonly ChromeNavControl[]; - currentAppId: string | undefined; - isLocked: boolean; - navType: NavType; - isOpen: boolean; +function renderMenuTrigger(toggleOpen: () => void) { + return ( + + + + ); } -export class Header extends Component { - private subscription?: Rx.Subscription; - private navDrawerRef = createRef(); - private toggleCollapsibleNavRef = createRef(); - - constructor(props: HeaderProps) { - super(props); - - let isLocked = false; - props.isLocked$.subscribe((initialIsLocked) => (isLocked = initialIsLocked)); - - this.state = { - appTitle: 'Kibana', - isVisible: true, - navLinks: [], - recentlyAccessed: [], - forceNavigation: false, - navControlsLeft: [], - navControlsRight: [], - currentAppId: '', - isLocked, - navType: 'modern', - isOpen: false, - }; +export function Header({ + kibanaVersion, + kibanaDocLink, + legacyMode, + application, + basePath, + onIsLockedUpdate, + homeHref, + ...observables +}: HeaderProps) { + const isVisible = useObservable(observables.isVisible$, true); + const navType = useObservable(observables.navType$, 'modern'); + const isLocked = useObservable(observables.isLocked$, false); + const [isOpen, setIsOpen] = useState(false); + + if (!isVisible) { + return ; } - public componentDidMount() { - this.subscription = Rx.combineLatest( - this.props.appTitle$, - this.props.isVisible$, - this.props.forceAppSwitcherNavigation$, - this.props.navLinks$, - this.props.recentlyAccessed$, - // Types for combineLatest only handle up to 6 inferred types so we combine these separately. - Rx.combineLatest( - this.props.navControlsLeft$, - this.props.navControlsRight$, - this.props.application.currentAppId$, - this.props.isLocked$, - this.props.navType$ - ) - ).subscribe({ - next: ([ - appTitle, - isVisible, - forceNavigation, - navLinks, - recentlyAccessed, - [navControlsLeft, navControlsRight, currentAppId, isLocked, navType], - ]) => { - this.setState({ - appTitle, - isVisible, - forceNavigation, - navLinks: navLinks.filter((navLink) => !navLink.hidden), - recentlyAccessed, - navControlsLeft, - navControlsRight, - currentAppId, - isLocked, - navType, - }); - }, - }); - } - - public componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); + const navDrawerRef = createRef(); + const toggleCollapsibleNavRef = createRef(); + const navId = htmlIdGenerator()(); + const className = classnames( + 'chrHeaderWrapper', // TODO #64541 - delete this + 'hide-for-sharing', + { + 'chrHeaderWrapper--navIsLocked': isLocked, + headerWrapper: navType === 'modern', } - } - - public renderMenuTrigger() { - return ( - this.navDrawerRef.current?.toggleOpen()} - > - - - ); - } - - public render() { - const { appTitle, isVisible, navControlsLeft, navControlsRight } = this.state; - const { - badge$, - breadcrumbs$, - helpExtension$, - helpSupportUrl$, - kibanaDocLink, - kibanaVersion, - } = this.props; - const navLinks = this.state.navLinks.map((link) => - createNavLink( - link, - this.props.legacyMode, - this.state.currentAppId, - this.props.basePath, - this.props.application.navigateToApp - ) - ); - const recentNavLinks = this.state.recentlyAccessed.map((link) => - createRecentNavLink(link, this.state.navLinks, this.props.basePath) - ); + ); - if (!isVisible) { - return null; - } - - const className = classnames( - 'chrHeaderWrapper', // TODO #64541 - delete this - 'hide-for-sharing', - { - 'chrHeaderWrapper--navIsLocked': this.state.isLocked, - headerWrapper: this.state.navType === 'modern', - } - ); - const navId = htmlIdGenerator()(); - return ( + return ( + <> +
    - {this.state.navType === 'modern' ? ( + {navType === 'modern' ? ( { - this.setState({ isOpen: !this.state.isOpen }); - }} - aria-expanded={this.state.isOpen} - aria-pressed={this.state.isOpen} + onClick={() => setIsOpen(!isOpen)} + aria-expanded={isOpen} + aria-pressed={isOpen} aria-controls={navId} - ref={this.toggleCollapsibleNavRef} + ref={toggleCollapsibleNavRef} > @@ -236,71 +147,79 @@ export class Header extends Component { // Delete this block - {this.renderMenuTrigger()} + {renderMenuTrigger(() => navDrawerRef.current?.toggleOpen())} )} - + - + - + - + - {this.state.navType === 'modern' ? ( + {navType === 'modern' ? ( { - this.setState({ isOpen }); - if (this.toggleCollapsibleNavRef.current) { - this.toggleCollapsibleNavRef.current.focus(); + isLocked={isLocked} + navLinks$={observables.navLinks$} + recentlyAccessed$={observables.recentlyAccessed$} + isOpen={isOpen} + homeHref={homeHref} + basePath={basePath} + legacyMode={legacyMode} + navigateToApp={application.navigateToApp} + onIsLockedUpdate={onIsLockedUpdate} + closeNav={() => { + setIsOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); } }} - navigateToApp={this.props.application.navigateToApp} /> ) : ( // TODO #64541 // Delete this block )}
    - ); - } + + ); } diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx index 0398f162f9af9..7fe2c91087090 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx @@ -19,26 +19,23 @@ import { mount } from 'enzyme'; import React from 'react'; -import * as Rx from 'rxjs'; - -import { ChromeBreadcrumb } from '../../chrome_service'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; describe('HeaderBreadcrumbs', () => { it('renders updates to the breadcrumbs$ observable', () => { - const breadcrumbs$ = new Rx.Subject(); - const wrapper = mount(); - - breadcrumbs$.next([{ text: 'First' }]); - // Unfortunately, enzyme won't update the wrapper until we call update. - wrapper.update(); + const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]); + const wrapper = mount( + + ); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); - breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }]); + act(() => breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }])); wrapper.update(); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); - breadcrumbs$.next([]); + act(() => breadcrumbs$.next([])); wrapper.update(); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); }); diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 54cfc7131cb2b..174c46981db53 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -17,88 +17,36 @@ * under the License. */ -import classNames from 'classnames'; -import React, { Component } from 'react'; -import * as Rx from 'rxjs'; - import { EuiHeaderBreadcrumbs } from '@elastic/eui'; +import classNames from 'classnames'; +import React from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; import { ChromeBreadcrumb } from '../../chrome_service'; interface Props { - appTitle?: string; - breadcrumbs$: Rx.Observable; + appTitle$: Observable; + breadcrumbs$: Observable; } -interface State { - breadcrumbs: ChromeBreadcrumb[]; -} - -export class HeaderBreadcrumbs extends Component { - private subscription?: Rx.Subscription; - - constructor(props: Props) { - super(props); +export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$ }: Props) { + const appTitle = useObservable(appTitle$, 'Kibana'); + const breadcrumbs = useObservable(breadcrumbs$, []); + let crumbs = breadcrumbs; - this.state = { breadcrumbs: [] }; + if (breadcrumbs.length === 0 && appTitle) { + crumbs = [{ text: appTitle }]; } - public componentDidMount() { - this.subscribe(); - } - - public componentDidUpdate(prevProps: Props) { - if (prevProps.breadcrumbs$ === this.props.breadcrumbs$) { - return; - } + crumbs = crumbs.map((breadcrumb, i) => ({ + ...breadcrumb, + 'data-test-subj': classNames( + 'breadcrumb', + breadcrumb['data-test-subj'], + i === 0 && 'first', + i === breadcrumbs.length - 1 && 'last' + ), + })); - this.unsubscribe(); - this.subscribe(); - } - - public componentWillUnmount() { - this.unsubscribe(); - } - - public render() { - return ( - - ); - } - - private subscribe() { - this.subscription = this.props.breadcrumbs$.subscribe((breadcrumbs) => { - this.setState({ - breadcrumbs, - }); - }); - } - - private unsubscribe() { - if (this.subscription) { - this.subscription.unsubscribe(); - delete this.subscription; - } - } - - private getBreadcrumbs() { - let breadcrumbs = this.state.breadcrumbs; - - if (breadcrumbs.length === 0 && this.props.appTitle) { - breadcrumbs = [{ text: this.props.appTitle }]; - } - - return breadcrumbs.map((breadcrumb, i) => ({ - ...breadcrumb, - 'data-test-subj': classNames( - 'breadcrumb', - breadcrumb['data-test-subj'], - i === 0 && 'first', - i === breadcrumbs.length - 1 && 'last' - ), - })); - } + return ; } diff --git a/src/core/public/chrome/ui/header/header_logo.tsx b/src/core/public/chrome/ui/header/header_logo.tsx index 147c7cf5dc4b1..9bec946b6b76e 100644 --- a/src/core/public/chrome/ui/header/header_logo.tsx +++ b/src/core/public/chrome/ui/header/header_logo.tsx @@ -17,11 +17,13 @@ * under the License. */ -import Url from 'url'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiHeaderLogo } from '@elastic/eui'; -import { NavLink } from './nav_link'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import Url from 'url'; +import { ChromeNavLink } from '../..'; function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { let current = element; @@ -41,7 +43,7 @@ function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { function onClick( event: React.MouseEvent, forceNavigation: boolean, - navLinks: NavLink[], + navLinks: ChromeNavLink[], navigateToApp: (appId: string) => void ) { const anchor = findClosestAnchor((event as any).nativeEvent.target); @@ -50,7 +52,7 @@ function onClick( } const navLink = navLinks.find((item) => item.href === anchor.href); - if (navLink && navLink.isDisabled) { + if (navLink && navLink.disabled) { event.preventDefault(); return; } @@ -85,12 +87,15 @@ function onClick( interface Props { href: string; - navLinks: NavLink[]; - forceNavigation: boolean; + navLinks$: Observable; + forceNavigation$: Observable; navigateToApp: (appId: string) => void; } -export function HeaderLogo({ href, forceNavigation, navLinks, navigateToApp }: Props) { +export function HeaderLogo({ href, navigateToApp, ...observables }: Props) { + const forceNavigation = useObservable(observables.forceNavigation$, false); + const navLinks = useObservable(observables.navLinks$, []); + return ( ; side: 'left' | 'right'; } -export class HeaderNavControls extends Component { - public render() { - const { navControls } = this.props; - - if (!navControls) { - return null; - } +export function HeaderNavControls({ navControls$, side }: Props) { + const navControls = useObservable(navControls$, []); - return navControls.map(this.renderNavControl); + if (!navControls) { + return null; } // It should be performant to use the index as the key since these are unlikely // to change while Kibana is running. - private renderNavControl = (navControl: ChromeNavControl, index: number) => ( - - - + return ( + <> + {navControls.map((navControl: ChromeNavControl, index: number) => ( + + + + ))} + ); } diff --git a/src/core/public/chrome/ui/header/nav_drawer.tsx b/src/core/public/chrome/ui/header/nav_drawer.tsx index 17df8569f6307..ee4bff6cc0ac4 100644 --- a/src/core/public/chrome/ui/header/nav_drawer.tsx +++ b/src/core/public/chrome/ui/header/nav_drawer.tsx @@ -17,24 +17,39 @@ * under the License. */ -import React from 'react'; +import { EuiHorizontalRule, EuiNavDrawer, EuiNavDrawerGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiNavDrawer, EuiHorizontalRule, EuiNavDrawerGroup } from '@elastic/eui'; +import React from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { InternalApplicationStart } from '../../../application/types'; +import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { NavLink, RecentNavLink } from './nav_link'; +import { createEuiListItem, createRecentNavLink } from './nav_link'; import { RecentLinks } from './recent_links'; export interface Props { + appId$: InternalApplicationStart['currentAppId$']; + basePath: HttpStart['basePath']; isLocked?: boolean; + legacyMode: boolean; + navLinks$: Observable; + recentlyAccessed$: Observable; + navigateToApp: CoreStart['application']['navigateToApp']; onIsLockedUpdate?: OnIsLockedUpdate; - navLinks: NavLink[]; - recentNavLinks: RecentNavLink[]; } -function navDrawerRenderer( - { isLocked, onIsLockedUpdate, navLinks, recentNavLinks }: Props, +function NavDrawerRenderer( + { isLocked, onIsLockedUpdate, basePath, legacyMode, navigateToApp, ...observables }: Props, ref: React.Ref ) { + const appId = useObservable(observables.appId$, ''); + const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const recentNavLinks = useObservable(observables.recentlyAccessed$, []).map((link) => + createRecentNavLink(link, navLinks, basePath) + ); + return ( + createEuiListItem({ + link, + legacyMode, + appId, + basePath, + navigateToApp, + dataTestSubj: 'navDrawerAppsMenuLink', + }) + )} aria-label={i18n.translate('core.ui.primaryNavList.screenReaderLabel', { defaultMessage: 'Primary navigation links', })} @@ -58,4 +82,4 @@ function navDrawerRenderer( ); } -export const NavDrawer = React.forwardRef(navDrawerRenderer); +export const NavDrawer = React.forwardRef(NavDrawerRenderer); diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index c979bb8271e1b..c09b15fac9bdb 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -17,12 +17,12 @@ * under the License. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiImage } from '@elastic/eui'; -import { AppCategory } from 'src/core/types'; -import { ChromeNavLink, CoreStart, ChromeRecentlyAccessedHistoryItem } from '../../../'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; +import { relativeToAbsolute } from '../../nav_links/to_nav_link'; function isModifiedEvent(event: React.MouseEvent) { return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); @@ -32,62 +32,37 @@ function LinkIcon({ url }: { url: string }) { return ; } -export interface NavLink { - key: string; - label: string; - href: string; - isActive: boolean; - onClick(event: React.MouseEvent): void; - category?: AppCategory; - isDisabled?: boolean; - iconType?: string; - icon?: JSX.Element; - order?: number; - 'data-test-subj': string; +interface Props { + link: ChromeNavLink; + legacyMode: boolean; + appId: string | undefined; + basePath?: HttpStart['basePath']; + dataTestSubj: string; + onClick?: Function; + navigateToApp: CoreStart['application']['navigateToApp']; } -/** - * Create a link that's actually ready to be passed into EUI - * - * @param navLink - * @param legacyMode - * @param currentAppId - * @param basePath - * @param navigateToApp - */ -export function createNavLink( - navLink: ChromeNavLink, - legacyMode: boolean, - currentAppId: string | undefined, - basePath: HttpStart['basePath'], - navigateToApp: CoreStart['application']['navigateToApp'] -): NavLink { - const { - legacy, - url, - active, - baseUrl, - id, - title, - disabled, - euiIconType, - icon, - category, - order, - tooltip, - } = navLink; - let href = navLink.url ?? navLink.baseUrl; - - if (legacy) { - href = url && !active ? url : baseUrl; - } +// TODO #64541 +// Set return type to EuiListGroupItemProps +// Currently it's a subset of EuiListGroupItemProps+FlyoutMenuItem for CollapsibleNav and NavDrawer +// But FlyoutMenuItem isn't exported from EUI +export function createEuiListItem({ + link, + legacyMode, + appId, + basePath, + onClick = () => {}, + navigateToApp, + dataTestSubj, +}: Props) { + const { legacy, active, id, title, disabled, euiIconType, icon, tooltip, href } = link; return { - category, - key: id, label: tooltip ?? title, - href, // Use href and onClick to support "open in new tab" and SPA navigation in the same link - onClick(event) { + href, + /* Use href and onClick to support "open in new tab" and SPA navigation in the same link */ + onClick(event: React.MouseEvent) { + onClick(); if ( !legacyMode && // ignore when in legacy mode !legacy && // ignore links to legacy apps @@ -96,57 +71,31 @@ export function createNavLink( !isModifiedEvent(event) // ignore clicks with modifier keys ) { event.preventDefault(); - navigateToApp(navLink.id); + navigateToApp(id); } }, // Legacy apps use `active` property, NP apps should match the current app - isActive: active || currentAppId === id, + isActive: active || appId === id, isDisabled: disabled, - iconType: euiIconType, - icon: !euiIconType && icon ? : undefined, - order, - 'data-test-subj': 'navDrawerAppsMenuLink', + 'data-test-subj': dataTestSubj, + ...(basePath && { + iconType: euiIconType, + icon: !euiIconType && icon ? : undefined, + }), }; } -// Providing a buffer between the limit and the cut off index -// protects from truncating just the last couple (6) characters -const TRUNCATE_LIMIT: number = 64; -const TRUNCATE_AT: number = 58; - -function truncateRecentItemLabel(label: string): string { - if (label.length > TRUNCATE_LIMIT) { - label = `${label.substring(0, TRUNCATE_AT)}…`; - } - - return label; -} - -/** - * @param {string} url - a relative or root relative url. If a relative path is given then the - * absolute url returned will depend on the current page where this function is called from. For example - * if you are on page "http://www.mysite.com/shopping/kids" and you pass this function "adults", you would get - * back "http://www.mysite.com/shopping/adults". If you passed this function a root relative path, or one that - * starts with a "/", for example "/account/cart", you would get back "http://www.mysite.com/account/cart". - * @return {string} the relative url transformed into an absolute url - */ -function relativeToAbsolute(url: string) { - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} - export interface RecentNavLink { href: string; label: string; title: string; 'aria-label': string; iconType?: string; - onClick?(event: React.MouseEvent): void; } /** * Add saved object type info to recently links + * TODO #64541 - set return type to EuiListGroupItemProps * * Recent nav links are similar to normal nav links but are missing some Kibana Platform magic and * because of legacy reasons have slightly different properties. @@ -176,7 +125,7 @@ export function createRecentNavLink( return { href, - label: truncateRecentItemLabel(label), + label, title: titleAndAriaLabel, 'aria-label': titleAndAriaLabel, iconType: navLink?.euiIconType, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4ccded9b9afec..90c5dbb5f6558 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -286,6 +286,7 @@ export interface ChromeNavLink { readonly disableSubUrlTracking?: boolean; readonly euiIconType?: string; readonly hidden?: boolean; + readonly href?: string; readonly icon?: string; readonly id: string; // @internal @@ -314,7 +315,7 @@ export interface ChromeNavLinks { } // @public (undocumented) -export type ChromeNavLinkUpdateableFields = Partial>; +export type ChromeNavLinkUpdateableFields = Partial>; // @public export interface ChromeRecentlyAccessed { diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts index 2567ca790e04f..98f8d874c7088 100644 --- a/src/core/server/legacy/types.ts +++ b/src/core/server/legacy/types.ts @@ -151,7 +151,7 @@ export type LegacyAppSpec = Partial & { * @internal * @deprecated */ -export type LegacyNavLink = Omit & { +export type LegacyNavLink = Omit & { order: number; }; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 6fdfda2d4acfd..f301aafc2ad00 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1376,7 +1376,7 @@ export interface QueryState { // Warning: (ae-missing-release-tag) "QueryStringInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const QueryStringInput: React.FC>; +export const QueryStringInput: React.FC>; // @public (undocumented) export type QuerySuggestion = QuerySuggestionBasic | QuerySuggestionField; @@ -1588,8 +1588,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts