diff --git a/src/ui/public/chrome/api/nav.d.ts b/src/ui/public/chrome/api/nav.d.ts new file mode 100644 index 0000000000000..dc169ed6bbb5c --- /dev/null +++ b/src/ui/public/chrome/api/nav.d.ts @@ -0,0 +1,44 @@ +/* + * 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 { IconType } from '@elastic/eui'; +import * as Rx from 'rxjs'; + +import { KibanaParsedUrl } from 'ui/url/kibana_parsed_url'; + +export interface NavLink { + title: string; + url: string; + subUrlBase: string; + id: string; + euiIconType: IconType; + active: boolean; + lastSubUrl?: string; + hidden?: boolean; +} + +export interface ChromeNavLinks { + getNavLinks$(): Rx.Observable; + getNavLinks(): NavLink[]; + navLinkExists(id: string): boolean; + getNavLinkById(id: string): NavLink; + showOnlyById(id: string): void; + untrackNavLinksForDeletedSavedObjects(deletedIds: string[]): void; + trackSubUrlForApp(linkId: string, parsedKibanaUrl: KibanaParsedUrl): void; +} diff --git a/src/ui/public/chrome/directives/header_global_nav/_header_global_nav.scss b/src/ui/public/chrome/directives/header_global_nav/_header_global_nav.scss index 11a38ba689924..dab2adcb7099c 100644 --- a/src/ui/public/chrome/directives/header_global_nav/_header_global_nav.scss +++ b/src/ui/public/chrome/directives/header_global_nav/_header_global_nav.scss @@ -1,3 +1,5 @@ +@import '@elastic/eui/src/components/header/variables'; + .header-global-wrapper { width: 100%; position: fixed; @@ -6,23 +8,19 @@ } .header-global-wrapper + .app-wrapper:not(.hidden-chrome) { - top: 48px; - left: 0; + top: $euiHeaderChildSize; + left: $euiHeaderChildSize; // HOTFIX: Temporary fix for flyouts not inside portals // SASSTODO: Find an actual solution .euiFlyout { - top: 48px; + top: $euiHeaderChildSize; } } // Mobile header is smaller -@include euiBreakpoint('xs','s') { - .header-global-wrapper + .app-wrapper { - top: 36px; - - .euiFlyout { - top: 36px; - } +@include euiBreakpoint('xs', 's') { + .header-global-wrapper + .app-wrapper:not(.hidden-chrome) { + left: 0; } } diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header.tsx index 2e2d91ad5e2c2..29975023a18ca 100644 --- a/src/ui/public/chrome/directives/header_global_nav/components/header.tsx +++ b/src/ui/public/chrome/directives/header_global_nav/components/header.tsx @@ -17,7 +17,8 @@ * under the License. */ -import React, { Component } from 'react'; +import classNames from 'classnames'; +import React, { Component, Fragment } from 'react'; import * as Rx from 'rxjs'; import { @@ -30,15 +31,35 @@ import { EuiHeaderSection, // @ts-ignore EuiHeaderSectionItem, + // @ts-ignore + EuiHeaderSectionItemButton, + // @ts-ignore + EuiHideFor, + EuiHorizontalRule, + EuiIcon, + EuiListGroup, + // @ts-ignore + EuiListGroupItem, + // @ts-ignore + EuiNavDrawer, + // @ts-ignore + EuiNavDrawerFlyout, + // @ts-ignore + EuiNavDrawerMenu, + EuiOutsideClickDetector, + // @ts-ignore + EuiShowFor, } from '@elastic/eui'; -import { HeaderAppMenu } from './header_app_menu'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; import { HeaderNavControls } from './header_nav_controls'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import chrome, { NavLink } from 'ui/chrome'; +import { RecentlyAccessedHistoryItem } from 'ui/persisted_log'; import { ChromeHeaderNavControlsRegistry } from 'ui/registry/chrome_header_nav_controls'; -import { NavControlSide, NavLink } from '../'; +import { relativeToAbsolute } from 'ui/url/relative_to_absolute'; +import { NavControlSide } from '../'; import { Breadcrumb } from '../../../../../../core/public/chrome'; interface Props { @@ -47,17 +68,89 @@ interface Props { homeHref: string; isVisible: boolean; navLinks$: Rx.Observable; + recentlyAccessed$: Rx.Observable; navControls: ChromeHeaderNavControlsRegistry; intl: InjectedIntl; } -class HeaderUI extends Component { +function extendRecentlyAccessedHistoryItem( + navLinks: NavLink[], + recentlyAccessed: RecentlyAccessedHistoryItem +) { + const href = relativeToAbsolute(chrome.addBasePath(recentlyAccessed.link)); + const navLink = navLinks.find(nl => href.startsWith(nl.subUrlBase)); + + return { + ...recentlyAccessed, + href, + euiIconType: navLink ? navLink.euiIconType : undefined, + }; +} + +interface State { + isCollapsed: boolean; + flyoutIsCollapsed: boolean; + flyoutIsAnimating: boolean; + navFlyoutTitle: string; + navFlyoutContent: []; + mobileIsHidden: boolean; + showScrollbar: boolean; + outsideClickDisabled: boolean; + isManagingFocus: boolean; + navLinks: NavLink[]; + recentlyAccessed: Array>; +} + +class HeaderUI extends Component { + private subscription?: Rx.Subscription; + private timeoutID?: ReturnType; + + constructor(props: Props) { + super(props); + + this.state = { + isCollapsed: true, + flyoutIsCollapsed: true, + flyoutIsAnimating: false, + navFlyoutTitle: '', + navFlyoutContent: [], + mobileIsHidden: true, + showScrollbar: false, + outsideClickDisabled: true, + isManagingFocus: false, + navLinks: [], + recentlyAccessed: [], + }; + } + + public componentDidMount() { + this.subscription = Rx.combineLatest( + this.props.navLinks$, + this.props.recentlyAccessed$ + ).subscribe({ + next: ([navLinks, recentlyAccessed]) => { + this.setState({ + navLinks, + recentlyAccessed: recentlyAccessed.map(ra => + extendRecentlyAccessedHistoryItem(navLinks, ra) + ), + }); + }, + }); + } + + public componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + public renderLogo() { const { homeHref, intl } = this.props; return ( { ); } + public renderMenuTrigger() { + return ( + + + + ); + } + public render() { - const { appTitle, breadcrumbs$, isVisible, navControls, navLinks$ } = this.props; + const { appTitle, breadcrumbs$, isVisible, navControls } = this.props; + const { navLinks, recentlyAccessed } = this.state; if (!isVisible) { return null; @@ -78,25 +180,212 @@ class HeaderUI extends Component { const rightNavControls = navControls.bySide[NavControlSide.Right]; return ( - - - {this.renderLogo()} + + + + + {this.renderMenuTrigger()} + - - + {this.renderLogo()} - + + - - + - - - - - + + + + + this.collapseDrawer()} + isDisabled={this.state.outsideClickDisabled} + > + + + + this.expandFlyout()} + isDisabled={recentlyAccessed.length > 0 ? false : true} + extraAction={{ + color: 'subdued', + iconType: 'arrowRight', + iconSize: 's', + 'aria-label': 'Expand to view recent apps and objects', + onClick: () => this.expandFlyout(), + alwaysShow: true, + }} + /> + + + + {navLinks.map(navLink => + navLink.hidden ? null : ( + + ) + )} + + + ({ + label: item.label, + href: item.href, + iconType: item.euiIconType, + size: 's', + style: { color: 'inherit' }, + 'aria-label': item.label, + }))} + onMouseLeave={this.collapseFlyout} + wrapText={true} + /> + + + ); } + + private toggleOpen = () => { + this.setState({ + mobileIsHidden: !this.state.mobileIsHidden, + }); + + setTimeout(() => { + this.setState({ + outsideClickDisabled: this.state.mobileIsHidden ? true : false, + }); + }, this.getTimeoutMs(350)); + }; + + private expandDrawer = () => { + this.setState({ isCollapsed: false }); + + setTimeout(() => { + this.setState({ + showScrollbar: true, + }); + }, this.getTimeoutMs(350)); + + // This prevents the drawer from collapsing when tabbing through children + // by clearing the timeout thus cancelling the onBlur event (see focusOut). + // This means isManagingFocus remains true as long as a child element + // has focus. This is the case since React bubbles up onFocus and onBlur + // events from the child elements. + + if (this.timeoutID) { + clearTimeout(this.timeoutID); + } + + if (!this.state.isManagingFocus) { + this.setState({ + isManagingFocus: true, + }); + } + }; + + private collapseDrawer = () => { + this.setState({ + flyoutIsAnimating: false, + }); + + setTimeout(() => { + this.setState({ + isCollapsed: true, + flyoutIsCollapsed: true, + mobileIsHidden: true, + showScrollbar: false, + outsideClickDisabled: true, + }); + }, this.getTimeoutMs(350)); + + // Scrolls the menu and flyout back to top when the nav drawer collapses + setTimeout(() => { + const menuEl = document.getElementById('navDrawerMenu'); + if (menuEl) { + menuEl.scrollTop = 0; + } + + const flyoutEl = document.getElementById('navDrawerFlyout'); + if (flyoutEl) { + flyoutEl.scrollTop = 0; + } + }, this.getTimeoutMs(300)); + }; + + private focusOut = () => { + // This collapses the drawer when no children have focus (i.e. tabbed out). + // In other words, if focus does not bubble up from a child element, then + // the drawer will collapse. See the corresponding block in expandDrawer + // (called by onFocus) which cancels this operation via clearTimeout. + this.timeoutID = setTimeout(() => { + if (this.state.isManagingFocus) { + this.setState({ + isManagingFocus: false, + }); + + this.collapseDrawer(); + } + }, 0); + }; + + private expandFlyout = () => { + this.setState(() => ({ + flyoutIsCollapsed: !this.state.flyoutIsCollapsed, + })); + + this.setState({ + flyoutIsAnimating: true, + }); + }; + + private collapseFlyout = () => { + this.setState({ flyoutIsAnimating: true }); + + setTimeout(() => { + this.setState({ + flyoutIsCollapsed: true, + }); + }, this.getTimeoutMs(250)); + }; + + private getTimeoutMs = (defaultTimeout: number) => { + const uiSettings = chrome.getUiSettingsClient(); + return uiSettings.get('accessibility:disableAnimations') ? 0 : defaultTimeout; + }; } export const Header = injectI18n(HeaderUI); diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header_app_menu.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header_app_menu.tsx deleted file mode 100644 index e63826d2ccdf7..0000000000000 --- a/src/ui/public/chrome/directives/header_global_nav/components/header_app_menu.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * 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, { Component } from 'react'; -import * as Rx from 'rxjs'; - -import { - // TODO: add type annotations - // @ts-ignore - EuiHeaderSectionItemButton, - // @ts-ignore - EuiIcon, - // @ts-ignore - EuiKeyPadMenu, - // @ts-ignore - EuiKeyPadMenuItem, - EuiPopover, -} from '@elastic/eui'; - -import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; -import { NavLink } from '../'; - -interface Props { - navLinks$: Rx.Observable; - intl: InjectedIntl; -} - -interface State { - isOpen: boolean; - navLinks: NavLink[]; -} - -class HeaderAppMenuUI extends Component { - private subscription?: Rx.Subscription; - - constructor(props: Props) { - super(props); - - this.state = { - isOpen: false, - navLinks: [], - }; - } - - public componentDidMount() { - this.subscription = this.props.navLinks$.subscribe({ - next: navLinks => { - this.setState({ navLinks }); - }, - }); - } - - public componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - this.subscription = undefined; - } - } - - public render() { - const { intl } = this.props; - const { navLinks } = this.state; - - const button = ( - - - - ); - - return ( - - - {navLinks.map(this.renderNavLink)} - - - ); - } - - private onMenuButtonClick = () => { - this.setState({ - isOpen: !this.state.isOpen, - }); - }; - - private closeMenu = () => { - this.setState({ - isOpen: false, - }); - }; - - private renderNavLink = (navLink: NavLink) => ( - - - - ); -} - -export const HeaderAppMenu = injectI18n(HeaderAppMenuUI); diff --git a/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js b/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js index 8da834b1fa9f2..96e5765b6169f 100644 --- a/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js +++ b/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js @@ -26,6 +26,7 @@ import { injectI18nProvider } from '@kbn/i18n/react'; const module = uiModules.get('kibana'); module.directive('headerGlobalNav', (reactDirective, chrome, Private) => { + const { recentlyAccessed } = require('ui/persisted_log'); const navControls = Private(chromeHeaderNavControlsRegistry); const homeHref = chrome.addBasePath('/app/kibana#/home'); @@ -39,6 +40,7 @@ module.directive('headerGlobalNav', (reactDirective, chrome, Private) => { { breadcrumbs$: chrome.breadcrumbs.get$(), navLinks$: chrome.getNavLinks$(), + recentlyAccessed$: recentlyAccessed.get$(), navControls, homeHref }); diff --git a/src/ui/public/chrome/directives/header_global_nav/index.ts b/src/ui/public/chrome/directives/header_global_nav/index.ts index ba8fbf66e736c..73c50e533bcad 100644 --- a/src/ui/public/chrome/directives/header_global_nav/index.ts +++ b/src/ui/public/chrome/directives/header_global_nav/index.ts @@ -17,7 +17,6 @@ * under the License. */ -import { IconType } from '@elastic/eui'; import './header_global_nav'; export enum NavControlSide { @@ -31,12 +30,3 @@ export interface NavControl { side: NavControlSide; render: (targetDomElement: HTMLDivElement) => (() => void) | void; } - -export interface NavLink { - title: string; - url: string; - id: string; - euiIconType: IconType; - lastSubUrl?: string; - active: boolean; -} diff --git a/src/ui/public/chrome/index.d.ts b/src/ui/public/chrome/index.d.ts index a30bd94f8fc4d..b4f2732de11f6 100644 --- a/src/ui/public/chrome/index.d.ts +++ b/src/ui/public/chrome/index.d.ts @@ -19,13 +19,13 @@ import { Brand } from '../../../core/public/chrome'; import { BreadcrumbsApi } from './api/breadcrumbs'; -export { Breadcrumb } from './api/breadcrumbs'; +import { ChromeNavLinks } from './api/nav'; interface IInjector { get(injectable: string): T; } -declare interface Chrome { +declare interface Chrome extends ChromeNavLinks { breadcrumbs: BreadcrumbsApi; addBasePath(path: T): T; dangerouslyGetActiveInjector(): Promise; @@ -46,3 +46,5 @@ declare interface Chrome { declare const chrome: Chrome; export default chrome; +export { Breadcrumb } from './api/breadcrumbs'; +export { NavLink } from './api/nav'; diff --git a/src/ui/public/persisted_log/persisted_log.ts b/src/ui/public/persisted_log/persisted_log.ts index 209607b5a9a09..0824d17757311 100644 --- a/src/ui/public/persisted_log/persisted_log.ts +++ b/src/ui/public/persisted_log/persisted_log.ts @@ -18,6 +18,8 @@ */ import _ from 'lodash'; +import * as Rx from 'rxjs'; +import { map } from 'rxjs/operators'; import { Storage } from 'ui/storage'; const localStorage = new Storage(window.localStorage); @@ -40,6 +42,8 @@ export class PersistedLog { public storage: Storage; public items: T[]; + private update$ = new Rx.BehaviorSubject(undefined); + constructor(name: string, options: PersistedLogOptions = {}, storage = localStorage) { this.name = name; this.maxLength = @@ -76,10 +80,15 @@ export class PersistedLog { // persist the stack this.storage.set(this.name, this.items); + this.update$.next(undefined); return this.items; } public get() { return _.cloneDeep(this.items); } + + public get$() { + return this.update$.pipe(map(() => this.get())); + } } diff --git a/src/ui/public/persisted_log/recently_accessed.ts b/src/ui/public/persisted_log/recently_accessed.ts index 906646c6b49ab..45309db879ed2 100644 --- a/src/ui/public/persisted_log/recently_accessed.ts +++ b/src/ui/public/persisted_log/recently_accessed.ts @@ -51,6 +51,10 @@ class RecentlyAccessed { public get() { return this.history.get(); } + + public get$() { + return this.history.get$(); + } } export const recentlyAccessed = new RecentlyAccessed(); diff --git a/src/ui/public/styles/disable_animations/disable_animations.css b/src/ui/public/styles/disable_animations/disable_animations.css index 9cf9d9eb4e5f2..a5ca0f417e669 100644 --- a/src/ui/public/styles/disable_animations/disable_animations.css +++ b/src/ui/public/styles/disable_animations/disable_animations.css @@ -11,4 +11,7 @@ -webkit-transition-duration: 0s !important; transition-duration: 0s !important; + + -webkit-transition-delay: 0s !important; + transition-delay: 0s !important; } diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index 64e2a2fdc952e..dad40daf0d6a7 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -18,6 +18,7 @@ */ import expect from 'expect.js'; +import moment from 'moment'; export default function ({ getService, getPageObjects }) { const log = getService('log'); @@ -153,8 +154,13 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visualize.waitForVisualization(); await PageObjects.discover.brushHistogram(0, 1); await PageObjects.visualize.waitForVisualization(); - const actualTimeString = await PageObjects.header.getPrettyDuration(); - expect(actualTimeString).to.be('September 19th 2015, 23:59:02.606 to September 20th 2015, 02:56:40.744'); + const newFromTime = await PageObjects.header.getFromTime(); + const newToTime = await PageObjects.header.getToTime(); + + const newDurationHours = moment.duration(moment(newToTime) - moment(newFromTime)).asHours(); + if (newDurationHours < 1 || newDurationHours >= 5) { + throw new Error(`expected new duration of ${newDurationHours} hours to be between 1 and 5 hours`); + } }); it('should show correct initial chart interval of Auto', async function () { diff --git a/test/functional/apps/discover/index.js b/test/functional/apps/discover/index.js index 420c0de35115c..6e7aedc250351 100644 --- a/test/functional/apps/discover/index.js +++ b/test/functional/apps/discover/index.js @@ -25,7 +25,7 @@ export default function ({ getService, loadTestFile }) { this.tags('ciGroup6'); before(function () { - return browser.setWindowSize(1200, 800); + return browser.setWindowSize(1250, 800); }); after(function unloadMakelogs() { diff --git a/test/functional/page_objects/home_page.js b/test/functional/page_objects/home_page.js index 54c64e4bf0ed5..7c0d2f7faba63 100644 --- a/test/functional/page_objects/home_page.js +++ b/test/functional/page_objects/home_page.js @@ -22,11 +22,6 @@ export function HomePageProvider({ getService }) { const retry = getService('retry'); class HomePage { - - async clickKibanaIcon() { - await testSubjects.click('kibanaLogo'); - } - async clickSynopsis(title) { await testSubjects.click(`homeSynopsisLink${title}`); } diff --git a/test/functional/services/apps_menu.js b/test/functional/services/apps_menu.js index 2d7e7f49cf682..d57ad68f9297f 100644 --- a/test/functional/services/apps_menu.js +++ b/test/functional/services/apps_menu.js @@ -21,7 +21,7 @@ export function AppsMenuProvider({ getService }) { const testSubjects = getService('testSubjects'); const log = getService('log'); const retry = getService('retry'); - const flyout = getService('flyout'); + const globalNav = getService('globalNav'); return new class AppsMenu { async readLinks() { @@ -42,38 +42,31 @@ export function AppsMenuProvider({ getService }) { } async clickLink(appTitle) { - log.debug(`click "${appTitle}" tab`); - await this._ensureMenuOpen(); - const container = await testSubjects.find('appsMenu'); - const link = await container.findByPartialLinkText(appTitle); - await link.click(); + try { + log.debug(`click "${appTitle}" tab`); + await this._ensureMenuOpen(); + const container = await testSubjects.find('appsMenu'); + const link = await container.findByPartialLinkText(appTitle); + await link.click(); + } finally { + await this._ensureMenuClosed(); + } } async _ensureMenuOpen() { - // some apps render flyouts that cover the global nav menu, so we make sure all flyouts are - // closed before trying to use the appsMenu - await flyout.ensureAllClosed(); - - if (!await testSubjects.exists('appsMenu')) { - await testSubjects.click('appsMenuButton'); - await retry.waitFor('apps menu displayed', async () => ( - await testSubjects.exists('appsMenu') + if (!await testSubjects.exists('navDrawer&expanded')) { + await testSubjects.moveMouseTo('navDrawer'); + await retry.waitFor('apps drawer open', async () => ( + await testSubjects.exists('navDrawer&expanded') )); } } async _ensureMenuClosed() { - const [appsMenuButtonExists, appsMenuExists] = await Promise.all([ - testSubjects.exists('appsMenuButton'), - testSubjects.exists('appsMenu') - ]); - - if (appsMenuButtonExists && appsMenuExists) { - await testSubjects.click('appsMenuButton'); - await retry.waitFor('user menu closed', async () => ( - !await testSubjects.exists('appsMenu') - )); - } + await globalNav.moveMouseToLogo(); + await retry.waitFor('apps drawer closed', async () => ( + await testSubjects.exists('navDrawer&collapsed') + )); } }; } diff --git a/test/functional/services/global_nav.js b/test/functional/services/global_nav.js index 3cfcfc4e29012..4719f8172aa3a 100644 --- a/test/functional/services/global_nav.js +++ b/test/functional/services/global_nav.js @@ -21,6 +21,10 @@ export function GlobalNavProvider({ getService }) { const testSubjects = getService('testSubjects'); return new class GlobalNav { + async moveMouseToLogo() { + await testSubjects.moveMouseTo('headerGlobalNav logo'); + } + async clickLogo() { return await testSubjects.click('headerGlobalNav logo'); } diff --git a/x-pack/plugins/uptime/index.ts b/x-pack/plugins/uptime/index.ts index f369c3d73681d..ac9322e7bd141 100644 --- a/x-pack/plugins/uptime/index.ts +++ b/x-pack/plugins/uptime/index.ts @@ -23,7 +23,7 @@ export const uptime = (kibana: any) => description: 'The description text that will be shown to users in Kibana', }), icon: 'plugins/uptime/icons/heartbeat_white.svg', - euiIconType: 'heartbeatApp', + euiIconType: 'uptimeApp', title: 'Uptime', main: 'plugins/uptime/app', order: 8900,