From 06a2663cdf15275263265cb6c27436bfdf694e2f Mon Sep 17 00:00:00 2001 From: Michail Yasonik Date: Thu, 16 Jan 2020 15:01:10 -0500 Subject: [PATCH] refactoring header component to prep for writing tests --- src/core/public/chrome/ui/header/header.tsx | 461 +++++------------- .../ui/header/header_bits/header_logo.tsx | 104 ++++ .../chrome/ui/header/header_bits/index.tsx | 88 ++++ .../ui/header/header_bits/recent_links.tsx | 113 +++++ 4 files changed, 416 insertions(+), 350 deletions(-) create mode 100644 src/core/public/chrome/ui/header/header_bits/header_logo.tsx create mode 100644 src/core/public/chrome/ui/header/header_bits/index.tsx create mode 100644 src/core/public/chrome/ui/header/header_bits/recent_links.tsx diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index d2e1734aee7a7..27d111660f9c9 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -17,21 +17,14 @@ * under the License. */ -import Url from 'url'; - -import React, { Component, createRef } from 'react'; -import * as Rx from 'rxjs'; - import { // TODO: add type annotations EuiHeader, - EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem, EuiHeaderSectionItemButton, EuiHorizontalRule, EuiIcon, - EuiImage, // @ts-ignore EuiNavDrawer, // @ts-ignore @@ -39,165 +32,30 @@ import { // @ts-ignore EuiShowFor, } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; -import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; - import { groupBy, sortBy } from 'lodash'; -import { HeaderBadge } from './header_badge'; -import { HeaderBreadcrumbs } from './header_breadcrumbs'; -import { HeaderHelpMenu } from './header_help_menu'; -import { HeaderNavControls } from './header_nav_controls'; -import { AppCategory } from '../../../../types'; - +import React, { Component, createRef } from 'react'; +import * as Rx from 'rxjs'; import { ChromeBadge, ChromeBreadcrumb, + ChromeNavControl, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, - ChromeNavControl, } from '../..'; +import { AppCategory } from '../../../../types'; +import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; -import { InternalApplicationStart } from '../../../application/types'; -import { CoreStart } from '../../../'; - -// 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; - -/** - * @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; -} - -function euiRecentItem( - navLinks: ChromeNavLink[], - recentlyAccessed: ChromeRecentlyAccessedHistoryItem, - basePath: HttpStart['basePath'] -) { - const href = relativeToAbsolute(basePath.prepend(recentlyAccessed.link)); - const navLink = navLinks.find(nl => href.startsWith(nl.subUrlBase || nl.baseUrl)); - let titleAndAriaLabel = recentlyAccessed.label; - - if (navLink) { - titleAndAriaLabel = i18n.translate('core.ui.recentLinks.linkItem.screenReaderLabel', { - defaultMessage: '{recentlyAccessedItemLinklabel}, type: {pageType}', - values: { - recentlyAccessedItemLinklabel: recentlyAccessed.label, - pageType: navLink.title, - }, - }); - } - - return { - href, - label: truncateRecentItemLabel(recentlyAccessed.label), - title: titleAndAriaLabel, - 'aria-label': titleAndAriaLabel, - euiIconType: navLink?.euiIconType, - }; -} - -function euiNavLink( - navLink: ChromeNavLink, - legacyMode: boolean, - currentAppId: string | undefined, - basePath: HttpStart['basePath'], - navigateToApp: CoreStart['application']['navigateToApp'] -) { - const { - legacy, - url, - active, - baseUrl, - id, - title, - disabled, - euiIconType, - icon, - category, - order, - tooltip, - } = navLink; - let href = navLink.baseUrl; - - if (legacy) { - href = url && !active ? url : baseUrl; - } - - 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: MouseEvent) { - if ( - !legacyMode && // ignore when in legacy mode - !legacy && // ignore links to legacy apps - !event.defaultPrevented && // onClick prevented default - event.button === 0 && // ignore everything but left clicks - !isModifiedEvent(event) // ignore clicks with modifier keys - ) { - event.preventDefault(); - navigateToApp(navLink.id); - } - }, - // Legacy apps use `active` property, NP apps should match the current app - isActive: active || currentAppId === id, - isDisabled: disabled, - iconType: euiIconType, - icon: !euiIconType && icon ? renderLinkIcon(basePath.prepend(`/${icon}`)) : undefined, - order, - 'data-test-subj': 'navDrawerAppsMenuLink', - }; -} - -function renderLinkIcon(url: string) { - return ; -} - -function isModifiedEvent(event: MouseEvent) { - return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); -} - -function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { - let current = element; - while (current) { - if (current.tagName === 'A') { - return current as HTMLAnchorElement; - } - - if (!current.parentElement || current.parentElement === document.body) { - return undefined; - } - - current = current.parentElement; - } -} - -function truncateRecentItemLabel(label: string): string { - if (label.length > TRUNCATE_LIMIT) { - label = `${label.substring(0, TRUNCATE_AT)}…`; - } - - return label; -} +import { HeaderBadge } from './header_badge'; +import { HeaderBreadcrumbs } from './header_breadcrumbs'; +import { HeaderHelpMenu } from './header_help_menu'; +import { HeaderNavControls } from './header_nav_controls'; +import { RecentLinks, NavLink, HeaderLogo, euiNavLink } from './header_bits'; -export type HeaderProps = Pick>; export type NavSetting = 'grouped' | 'individual'; -interface Props { +interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; appTitle$: Rx.Observable; @@ -214,25 +72,22 @@ interface Props { legacyMode: boolean; navControlsLeft$: Rx.Observable; navControlsRight$: Rx.Observable; - intl: InjectedIntl; basePath: HttpStart['basePath']; isLocked?: boolean; navSetting$: Rx.Observable; onIsLockedUpdate?: (isLocked: boolean) => void; } -type ExtendedRecentlyAccessedHistoryItem = ReturnType; -type NavLink = ReturnType; - interface State { appTitle: string; isVisible: boolean; - navLinks: NavLink[]; - recentlyAccessed: ExtendedRecentlyAccessedHistoryItem[]; + navLinks: ChromeNavLink[]; + recentlyAccessed: ChromeRecentlyAccessedHistoryItem[]; forceNavigation: boolean; navControlsLeft: readonly ChromeNavControl[]; navControlsRight: readonly ChromeNavControl[]; navSetting: NavSetting; + currentAppId: string | undefined; } function getAllCategories(allCategorizedLinks: Record) { @@ -255,11 +110,11 @@ function getOrderedCategories( ); } -class HeaderUI extends Component { +export class Header extends Component { private subscription?: Rx.Subscription; private navDrawerRef = createRef(); - constructor(props: Props) { + constructor(props: HeaderProps) { super(props); this.state = { @@ -271,6 +126,7 @@ class HeaderUI extends Component { navControlsLeft: [], navControlsRight: [], navSetting: 'grouped', + currentAppId: '', }; } @@ -301,23 +157,12 @@ class HeaderUI extends Component { appTitle, isVisible, forceNavigation, - navLinks: navLinks - .filter(navLink => !navLink.hidden) - .map(navLink => - euiNavLink( - navLink, - this.props.legacyMode, - currentAppId, - this.props.basePath, - this.props.application.navigateToApp - ) - ), - recentlyAccessed: recentlyAccessed.map(ra => - euiRecentItem(navLinks, ra, this.props.basePath) - ), + navLinks: navLinks.filter(navLink => !navLink.hidden), + recentlyAccessed, navControlsLeft, navControlsRight, navSetting, + currentAppId, }); }, }); @@ -329,21 +174,6 @@ class HeaderUI extends Component { } } - public renderLogo() { - const { homeHref } = this.props; - return ( - - ); - } - public renderMenuTrigger() { return ( { ); } - public renderRecentLinks() { - return ( - 0), - flyoutMenu: { - title: i18n.translate('core.ui.chrome.sideGlobalNav.viewRecentItemsFlyoutTitle', { - defaultMessage: 'Recent items', - }), - listItems: this.state.recentlyAccessed.map(item => ({ - label: truncateRecentItemLabel(item.label), - title: item.title, - 'aria-label': item.title, - href: item.href, - iconType: item.euiIconType, - })), - }, - }, - ]} - aria-label={i18n.translate('core.ui.recentLinks.screenReaderLabel', { - defaultMessage: 'Recently viewed links, navigation', - })} - /> - ); - } - - public renderNavLinks() { + public renderNavLinks(navLinks: NavLink[]) { const disableGroupedNavSetting = this.state.navSetting === 'individual'; - const groupedNavLinks = groupBy(this.state.navLinks, link => link?.category?.label); + const groupedNavLinks = groupBy(navLinks, link => link?.category?.label); const { undefined: unknowns, ...allCategorizedLinks } = groupedNavLinks; const { Management: management, ...mainCategories } = allCategorizedLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(mainCategories, categoryDictionary); const showUngroupedNav = - disableGroupedNavSetting || - this.state.navLinks.length < 7 || - Object.keys(mainCategories).length === 1; - - if (showUngroupedNav) { - return ( - - {this.renderRecentLinks()} - - - - ); - } + disableGroupedNavSetting || navLinks.length < 7 || Object.keys(mainCategories).length === 1; return ( { defaultMessage: 'Primary', })} > - {this.renderRecentLinks()} - - { - const category = categoryDictionary[categoryName]!; - const links = mainCategories[categoryName]; - - if (links.length === 1) { - return { - ...links[0], - label: category.label, - iconType: category.euiIconType || links[0].iconType, - }; - } - - return { - 'data-test-subj': 'navDrawerCategory', - iconType: category.euiIconType, - label: category.label, - flyoutMenu: { - title: category.label, - listItems: sortBy(links, 'order').map(link => { - link['data-test-subj'] = 'navDrawerFlyoutLink'; - return link; - }), - }, - }; - }), - ...sortBy(unknowns, 'order'), - ]} - /> + {RecentLinks({ + recentlyAccessedItems: this.state.recentlyAccessed, + navLinks: this.state.navLinks, + basePath: this.props.basePath, + })} - { - link['data-test-subj'] = 'navDrawerFlyoutLink'; - return link; + {showUngroupedNav ? ( + + ) : ( + <> + { + const category = categoryDictionary[categoryName]!; + const links = mainCategories[categoryName]; + + if (links.length === 1) { + return { + ...links[0], + label: category.label, + iconType: category.euiIconType || links[0].iconType, + }; + } + + return { + 'data-test-subj': 'navDrawerCategory', + iconType: category.euiIconType, + label: category.label, + flyoutMenu: { + title: category.label, + listItems: sortBy(links, 'order').map(link => { + link['data-test-subj'] = 'navDrawerFlyoutLink'; + return link; + }), + }, + }; }), - }, - }, - ]} - /> + ...sortBy(unknowns, 'order'), + ]} + /> + + { + link['data-test-subj'] = 'navDrawerFlyoutLink'; + return link; + }), + }, + }, + ]} + /> + + )} ); } @@ -505,6 +294,15 @@ class HeaderUI extends Component { kibanaDocLink, kibanaVersion, } = this.props; + const navLinks = this.state.navLinks.map(link => + euiNavLink( + link, + this.props.legacyMode, + this.state.currentAppId, + this.props.basePath, + this.props.application.navigateToApp + ) + ); if (!isVisible) { return null; @@ -518,7 +316,13 @@ class HeaderUI extends Component { {this.renderMenuTrigger()} - {this.renderLogo()} + + + @@ -543,51 +347,8 @@ class HeaderUI extends Component { - {this.renderNavLinks()} + {this.renderNavLinks(navLinks)} ); } - - private onNavClick = (event: React.MouseEvent) => { - const anchor = findClosestAnchor((event as any).nativeEvent.target); - if (!anchor) { - return; - } - - const navLink = this.state.navLinks.find(item => item.href === anchor.href); - if (navLink && navLink.isDisabled) { - event.preventDefault(); - return; - } - - if ( - !this.state.forceNavigation || - event.isDefaultPrevented() || - event.altKey || - event.metaKey || - event.ctrlKey - ) { - return; - } - - const toParsed = Url.parse(anchor.href); - const fromParsed = Url.parse(document.location.href); - const sameProto = toParsed.protocol === fromParsed.protocol; - const sameHost = toParsed.host === fromParsed.host; - const samePath = toParsed.path === fromParsed.path; - - if (sameProto && sameHost && samePath) { - if (toParsed.hash) { - document.location.reload(); - } - - // event.preventDefault() keeps the browser from seeing the new url as an update - // and even setting window.location does not mimic that behavior, so instead - // we use stopPropagation() to prevent angular from seeing the click and - // starting a digest cycle/attempting to handle it in the router. - event.stopPropagation(); - } - }; } - -export const Header = injectI18n(HeaderUI); diff --git a/src/core/public/chrome/ui/header/header_bits/header_logo.tsx b/src/core/public/chrome/ui/header/header_bits/header_logo.tsx new file mode 100644 index 0000000000000..4e11a41f5ec65 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_bits/header_logo.tsx @@ -0,0 +1,104 @@ +/* + * 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 Url from 'url'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiHeaderLogo } from '@elastic/eui'; +import { NavLink } from './'; + +function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { + let current = element; + while (current) { + if (current.tagName === 'A') { + return current as HTMLAnchorElement; + } + + if (!current.parentElement || current.parentElement === document.body) { + return undefined; + } + + current = current.parentElement; + } +} + +function onClick( + event: React.MouseEvent, + forceNavigation: boolean, + navLinks: NavLink[] +) { + const anchor = findClosestAnchor((event as any).nativeEvent.target); + if (!anchor) { + return; + } + + const navLink = navLinks.find(item => item.href === anchor.href); + if (navLink && navLink.isDisabled) { + event.preventDefault(); + return; + } + + if ( + !forceNavigation || + event.isDefaultPrevented() || + event.altKey || + event.metaKey || + event.ctrlKey + ) { + return; + } + + const toParsed = Url.parse(anchor.href); + const fromParsed = Url.parse(document.location.href); + const sameProto = toParsed.protocol === fromParsed.protocol; + const sameHost = toParsed.host === fromParsed.host; + const samePath = toParsed.path === fromParsed.path; + + if (sameProto && sameHost && samePath) { + if (toParsed.hash) { + document.location.reload(); + } + + // event.preventDefault() keeps the browser from seeing the new url as an update + // and even setting window.location does not mimic that behavior, so instead + // we use stopPropagation() to prevent angular from seeing the click and + // starting a digest cycle/attempting to handle it in the router. + event.stopPropagation(); + } +} + +interface Props { + href: string; + navLinks: NavLink[]; + forceNavigation: boolean; +} + +export function HeaderLogo({ href, forceNavigation, navLinks }: Props) { + return ( + onClick(e, forceNavigation, navLinks)} + href={href} + aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel', { + defaultMessage: 'Go to home page', + })} + /> + ); +} diff --git a/src/core/public/chrome/ui/header/header_bits/index.tsx b/src/core/public/chrome/ui/header/header_bits/index.tsx new file mode 100644 index 0000000000000..cdcc65a2b3d9e --- /dev/null +++ b/src/core/public/chrome/ui/header/header_bits/index.tsx @@ -0,0 +1,88 @@ +/* + * 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 { EuiImage } from '@elastic/eui'; +import { ChromeNavLink, CoreStart } from '../../../../'; +import { HttpStart } from '../../../../http'; + +function isModifiedEvent(event: MouseEvent) { + return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); +} + +export function LinkIcon({ url }: { url: string }) { + return ; +} + +export { HeaderLogo } from './header_logo'; +export { RecentLinks } from './recent_links'; +export type NavLink = ReturnType; +export function euiNavLink( + navLink: ChromeNavLink, + legacyMode: boolean, + currentAppId: string | undefined, + basePath: HttpStart['basePath'], + navigateToApp: CoreStart['application']['navigateToApp'] +) { + const { + legacy, + url, + active, + baseUrl, + id, + title, + disabled, + euiIconType, + icon, + category, + order, + tooltip, + } = navLink; + let href = navLink.baseUrl; + + if (legacy) { + href = url && !active ? url : baseUrl; + } + + 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: MouseEvent) { + if ( + !legacyMode && // ignore when in legacy mode + !legacy && // ignore links to legacy apps + !event.defaultPrevented && // onClick prevented default + event.button === 0 && // ignore everything but left clicks + !isModifiedEvent(event) // ignore clicks with modifier keys + ) { + event.preventDefault(); + navigateToApp(navLink.id); + } + }, + // Legacy apps use `active` property, NP apps should match the current app + isActive: active || currentAppId === id, + isDisabled: disabled, + iconType: euiIconType, + icon: !euiIconType && icon ? : undefined, + order, + 'data-test-subj': 'navDrawerAppsMenuLink', + }; +} diff --git a/src/core/public/chrome/ui/header/header_bits/recent_links.tsx b/src/core/public/chrome/ui/header/header_bits/recent_links.tsx new file mode 100644 index 0000000000000..b5e5709959048 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_bits/recent_links.tsx @@ -0,0 +1,113 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +// @ts-ignore +import { EuiNavDrawerGroup } from '@elastic/eui'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../../..'; +import { HttpStart } from '../../../../http'; + +// 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; + +export 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; +} + +function prepareForEUI( + recentlyAccessed: ChromeRecentlyAccessedHistoryItem[], + navLinks: ChromeNavLink[], + basePath: HttpStart['basePath'] +) { + return recentlyAccessed.map(({ link, label }) => { + const href = relativeToAbsolute(basePath.prepend(link)); + const navLink = navLinks.find(nl => href.startsWith(nl.baseUrl ?? nl.subUrlBase)); + let titleAndAriaLabel = label; + + if (navLink) { + titleAndAriaLabel = i18n.translate('core.ui.recentLinks.linkItem.screenReaderLabel', { + defaultMessage: '{recentlyAccessedItemLinklabel}, type: {pageType}', + values: { + recentlyAccessedItemLinklabel: label, + pageType: navLink.title, + }, + }); + } + + return { + href, + label: truncateRecentItemLabel(label), + title: titleAndAriaLabel, + 'aria-label': titleAndAriaLabel, + iconType: navLink?.euiIconType, + }; + }); +} + +interface Props { + recentlyAccessedItems: ChromeRecentlyAccessedHistoryItem[]; + navLinks: ChromeNavLink[]; + basePath: HttpStart['basePath']; +} + +export function RecentLinks({ recentlyAccessedItems, navLinks, basePath }: Props) { + return ( + + ); +}