Add K7 header navigation (#23300)

* Add basic support for new K7 navigation

* Make visibility and app title work

* Allow nav controls on right side of navbar

* Use render callback w/ el

* Add support for multiple sides

* Remove fake spaces nav control

* Breadcrumb support

* Hide breadcrumbs in plugins when k7design is enabled:

* Fix units

* Rename k7 -> header

* Add tests

* Fix tests

* Fix loading indicator

* PR comments

* Move ts-ignore

* Use canvasApp icon type
This commit is contained in:
Josh Dover 2018-10-02 14:09:47 -05:00 committed by GitHub
parent f74b4bfdac
commit 49798bc8ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1162 additions and 53 deletions

View file

@ -2213,7 +2213,8 @@ main {
-ms-flex-align: center;
align-items: center;
padding-left: 10px;
/* 1 */ }
/* 1 */
height: 100%; }
.kuiLocalBreadcrumb {
font-size: 14px;
@ -2685,6 +2686,8 @@ main {
-ms-flex-pack: justify;
justify-content: space-between;
min-height: 29px;
/* 1 */
line-height: 29px;
/* 1 */ }
.kuiLocalNavRow__section {

View file

@ -6,6 +6,7 @@
display: flex;
align-items: center;
padding-left: $localNavSideSpacing; /* 1 */
height: 100%;
}
.kuiLocalBreadcrumb {

View file

@ -28,6 +28,7 @@
align-items: stretch;
justify-content: space-between;
min-height: 29px; /* 1 */
line-height: 29px; /* 1 */
}
.kuiLocalNavRow__section {

View file

@ -76,6 +76,7 @@ export default function (kibana) {
url: `${kbnBaseUrl}#/discover`,
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
euiIconType: 'discoverApp',
}, {
id: 'kibana:visualize',
title: 'Visualize',
@ -83,6 +84,7 @@ export default function (kibana) {
url: `${kbnBaseUrl}#/visualize`,
description: 'design data visualizations',
icon: 'plugins/kibana/assets/visualize.svg',
euiIconType: 'visualizeApp',
}, {
id: 'kibana:dashboard',
title: 'Dashboard',
@ -96,13 +98,15 @@ export default function (kibana) {
subUrlBase: `${kbnBaseUrl}#/dashboard`,
description: 'compose visualizations for much win',
icon: 'plugins/kibana/assets/dashboard.svg',
euiIconType: 'dashboardApp',
}, {
id: 'kibana:dev_tools',
title: 'Dev Tools',
order: 9001,
url: '/app/kibana#/dev_tools',
description: 'development tools',
icon: 'plugins/kibana/assets/wrench.svg'
icon: 'plugins/kibana/assets/wrench.svg',
euiIconType: 'devToolsApp',
}, {
id: 'kibana:management',
title: 'Management',
@ -110,6 +114,7 @@ export default function (kibana) {
url: `${kbnBaseUrl}#/management`,
description: 'define index patterns, change config, and more',
icon: 'plugins/kibana/assets/settings.svg',
euiIconType: 'managementApp',
linkToLastSubUrl: false
},
],

View file

@ -7,20 +7,21 @@
<!-- Transcluded elements. -->
<div data-transclude-slots>
<!-- Title. -->
<div
data-transclude-slot="topLeftCorner"
class="kuiLocalBreadcrumbs"
data-test-subj="breadcrumbs"
role="heading"
aria-level="1"
>
<div class="kuiLocalBreadcrumb">
<a class="kuiLocalBreadcrumb__link" href="{{landingPageUrl()}}">Dashboard</a>
<div data-transclude-slot="topLeftCorner">
<div
class="kuiLocalBreadcrumbs"
data-test-subj="breadcrumbs"
role="heading"
aria-level="1"
ng-if="showPluginBreadcrumbs">
<div class="kuiLocalBreadcrumb">
<a class="kuiLocalBreadcrumb__link" href="{{landingPageUrl()}}">Dashboard</a>
</div>
<div class="kuiLocalBreadcrumb">
{{ getDashTitle() }}
</div>
</div>
</div>
<div class="kuiLocalBreadcrumb">
{{ getDashTitle() }}
</div>
</div>
<!-- Search. -->
<div ng-show="chrome.getVisible()" class="fullWidth" data-transclude-slot="bottomRow">

View file

@ -81,7 +81,17 @@ app.directive('dashboardApp', function ($injector) {
return {
restrict: 'E',
controllerAs: 'dashboardApp',
controller: function ($scope, $rootScope, $route, $routeParams, $location, getAppState, dashboardConfig, localStorage) {
controller: function (
$scope,
$rootScope,
$route,
$routeParams,
$location,
getAppState,
dashboardConfig,
localStorage,
breadcrumbState
) {
const filterManager = Private(FilterManagerProvider);
const filterBar = Private(FilterBarQueryFilterProvider);
const docTitle = Private(DocTitleProvider);
@ -169,6 +179,18 @@ app.directive('dashboardApp', function ($injector) {
dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(),
dashboardStateManager.getIsDirty(timefilter));
// Push breadcrumbs to new header navigation
const updateBreadcrumbs = () => {
breadcrumbState.set([
{ text: 'Dashboard', href: $scope.landingPageUrl() },
{ text: $scope.getDashTitle() }
]);
};
updateBreadcrumbs();
dashboardStateManager.registerChangeListener(updateBreadcrumbs);
config.watch('k7design', (val) => $scope.showPluginBreadcrumbs = !val);
$scope.newDashboard = () => { kbnUrl.change(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}); };
$scope.saveState = () => dashboardStateManager.saveState();
$scope.getShouldShowEditHelp = () => (

View file

@ -50,7 +50,7 @@ uiRoutes
})
.when(DashboardConstants.LANDING_PAGE_PATH, {
template: dashboardListingTemplate,
controller($injector, $location, $scope, Private, config) {
controller($injector, $location, $scope, Private, config, breadcrumbState) {
const services = Private(SavedObjectRegistryProvider).byLoaderPropertiesName;
const dashboardConfig = $injector.get('dashboardConfig');
@ -63,6 +63,7 @@ uiRoutes
};
$scope.hideWriteControls = dashboardConfig.getHideWriteControls();
$scope.initialFilter = ($location.search()).filter || EMPTY_FILTER;
breadcrumbState.set([{ text: 'Dashboards' }]);
},
resolve: {
dash: function ($route, Private, redirectWhenMissing, kbnUrl) {

View file

@ -155,8 +155,8 @@ function discoverController(
courier,
kbnUrl,
localStorage,
breadcrumbState
) {
const Vis = Private(VisProvider);
const docTitle = Private(DocTitleProvider);
const HitSortFn = Private(PluginsKibanaDiscoverHitSortFnProvider);
@ -289,6 +289,12 @@ function discoverController(
const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : '';
docTitle.change(`Discover${pageTitleSuffix}`);
if (savedSearch.id && savedSearch.title) {
breadcrumbState.set([{ text: 'Discover', href: '#/discover' }, { text: savedSearch.title }]);
} else {
breadcrumbState.set([{ text: 'Discover' }]);
}
let stateMonitor;
const $state = $scope.state = new AppState(getStateDefaults());

View file

@ -5,11 +5,14 @@
<!-- Title. -->
<div
data-transclude-slot="topLeftCorner"
class="kuiLocalTitle"
role="heading"
aria-level="1"
>
Visualize
<div
class="kuiLocalTitle"
role="heading"
aria-level="1"
ng-if="listingController.showPluginBreadcrumbs">
Visualize
</div>
</div>
</div>
</kbn-top-nav>

View file

@ -34,6 +34,7 @@ export function VisualizeListingController($injector) {
const Notifier = $injector.get('Notifier');
const Private = $injector.get('Private');
const config = $injector.get('config');
const breadcrumbState = $injector.get('breadcrumbState');
timefilter.disableAutoRefreshSelector();
timefilter.disableTimeRangeSelector();
@ -58,4 +59,7 @@ export function VisualizeListingController($injector) {
return visualizationService.delete(selectedIds)
.catch(error => notify.error(error));
};
breadcrumbState.set([{ text: 'Visualize' }]);
config.watch('k7design', (val) => this.showPluginBreadcrumbs = !val);
}

View file

@ -44,6 +44,12 @@ export function getUiSettingDefaults() {
description: `When set, * is allowed as the first character in a query clause. Currently only applies when experimental query
features are enabled in the query bar. To disallow leading wildcards in basic lucene queries, use query:queryString:options`,
},
'k7design': {
name: 'Use the new K7 UI design',
value: false,
description: `When set, Kibana will use the new K7 design targeted for release in 7.0. At this time, not all features are
implemented.`,
},
'search:queryLanguage': {
name: 'Query language',
value: 'lucene',

View file

@ -26,6 +26,7 @@ export default function (kibana) {
order: -1000,
description: 'Time series expressions for everything',
icon: 'plugins/timelion/icon.svg',
euiIconType: 'timelionApp',
main: 'plugins/timelion/app',
},
styleSheetPaths: `${__dirname}/public/index.scss`,

View file

@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs observable 1`] = `
<span
aria-current="page"
className="euiBreadcrumb euiBreadcrumb--last"
title="First"
>
First
</span>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs observable 2`] = `
Array [
<EuiLink
className="euiBreadcrumb"
color="subdued"
title="First"
type="button"
>
<button
className="euiLink euiLink--subdued euiBreadcrumb"
title="First"
type="button"
>
First
</button>
</EuiLink>,
<button
className="euiLink euiLink--subdued euiBreadcrumb"
title="First"
type="button"
>
First
</button>,
<span
aria-current="page"
className="euiBreadcrumb euiBreadcrumb--last"
title="Second"
>
Second
</span>,
]
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs observable 3`] = `null`;

View file

@ -0,0 +1,87 @@
/*
* 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 { Subscribable } from 'rxjs';
import {
// TODO: add type annotations
// @ts-ignore
EuiHeader,
// @ts-ignore
EuiHeaderLogo,
// @ts-ignore
EuiHeaderSection,
// @ts-ignore
EuiHeaderSectionItem,
} from '@elastic/eui';
import { HeaderAppMenu } from './header_app_menu';
import { HeaderBreadcrumbs } from './header_breadcrumbs';
import { HeaderNavControls } from './header_nav_controls';
import { ChromeHeaderNavControlsRegistry } from 'ui/registry/chrome_header_nav_controls';
import { Breadcrumb, NavControlSide, NavLink } from '../';
interface Props {
appTitle?: string;
breadcrumbs: Subscribable<Breadcrumb[]>;
homeHref: string;
isVisible: boolean;
navLinks: NavLink[];
navControls: ChromeHeaderNavControlsRegistry;
}
export class Header extends Component<Props> {
public renderLogo() {
const { homeHref } = this.props;
return <EuiHeaderLogo iconType="logoKibana" href={homeHref} aria-label="Go to home page" />;
}
public render() {
const { appTitle, breadcrumbs, isVisible, navControls, navLinks } = this.props;
if (!isVisible) {
return null;
}
const leftNavControls = navControls.bySide[NavControlSide.Left];
const rightNavControls = navControls.bySide[NavControlSide.Right];
return (
<EuiHeader>
<EuiHeaderSection>
<EuiHeaderSectionItem border="right">{this.renderLogo()}</EuiHeaderSectionItem>
<HeaderNavControls navControls={leftNavControls} />
<HeaderBreadcrumbs appTitle={appTitle} breadcrumbs={breadcrumbs} />
</EuiHeaderSection>
<EuiHeaderSection side="right">
<HeaderNavControls navControls={rightNavControls} />
<EuiHeaderSectionItem>
<HeaderAppMenu navLinks={navLinks} />
</EuiHeaderSectionItem>
</EuiHeaderSection>
</EuiHeader>
);
}
}

View file

@ -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, { Component } from 'react';
import {
// TODO: add type annotations
// @ts-ignore
EuiHeaderSectionItemButton,
// @ts-ignore
EuiIcon,
// @ts-ignore
EuiKeyPadMenu,
// @ts-ignore
EuiKeyPadMenuItem,
EuiPopover,
} from '@elastic/eui';
import { NavLink } from '../';
interface Props {
navLinks: NavLink[];
}
interface State {
isOpen: boolean;
}
export class HeaderAppMenu extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
isOpen: false,
};
}
public render() {
const { navLinks = [] } = this.props;
const button = (
<EuiHeaderSectionItemButton
aria-controls="keyPadMenu"
aria-expanded={this.state.isOpen}
aria-haspopup="true"
aria-label="Apps menu"
onClick={this.onMenuButtonClick}
>
<EuiIcon type="apps" size="m" />
</EuiHeaderSectionItemButton>
);
return (
<EuiPopover
id="headerAppMenu"
button={button}
isOpen={this.state.isOpen}
anchorPosition="downRight"
closePopover={this.closeMenu}
>
<EuiKeyPadMenu id="keyPadMenu" style={{ width: 288 }}>
{navLinks.map(this.renderNavLink)}
</EuiKeyPadMenu>
</EuiPopover>
);
}
private onMenuButtonClick = () => {
this.setState({
isOpen: !this.state.isOpen,
});
};
private closeMenu = () => {
this.setState({
isOpen: false,
});
};
private renderNavLink = (navLink: NavLink) => (
<EuiKeyPadMenuItem
label={navLink.title}
href={navLink.url}
key={navLink.id}
onClick={this.closeMenu}
>
<EuiIcon type={navLink.euiIconType} size="l" />
</EuiKeyPadMenuItem>
);
}

View file

@ -0,0 +1,42 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { breadcrumbs, set } from '../../../services/breadcrumb_state';
import { HeaderBreadcrumbs } from './header_breadcrumbs';
describe('HeaderBreadcrumbs', () => {
it('renders updates to the breadcrumbs observable', () => {
const wrapper = mount(<HeaderBreadcrumbs breadcrumbs={breadcrumbs} />);
set([{ text: 'First' }]);
// Unfortunately, enzyme won't update the wrapper until we call update.
wrapper.update();
expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot();
set([{ text: 'First' }, { text: 'Second' }]);
wrapper.update();
expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot();
set([]);
wrapper.update();
expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot();
});
});

View file

@ -0,0 +1,87 @@
/*
* 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 { Subscribable, Unsubscribable } from 'rxjs';
import {
// @ts-ignore
EuiHeaderBreadcrumbs,
} from '@elastic/eui';
import { Breadcrumb } from '../';
interface Props {
appTitle?: string;
breadcrumbs: Subscribable<Breadcrumb[]>;
}
interface State {
breadcrumbs: Breadcrumb[];
}
export class HeaderBreadcrumbs extends Component<Props, State> {
private unsubscribable?: Unsubscribable;
constructor(props: Props) {
super(props);
this.state = { breadcrumbs: [] };
}
public componentDidMount() {
this.subscribe();
}
public componentDidUpdate(prevProps: Props) {
if (prevProps.breadcrumbs === this.props.breadcrumbs) {
return;
}
this.unsubscribe();
this.subscribe();
}
public componentWillUnmount() {
this.unsubscribe();
}
public render() {
let breadcrumbs = this.state.breadcrumbs;
if (breadcrumbs.length === 0 && this.props.appTitle) {
breadcrumbs = [{ text: this.props.appTitle }];
}
return <EuiHeaderBreadcrumbs breadcrumbs={breadcrumbs} />;
}
private subscribe() {
this.unsubscribable = this.props.breadcrumbs.subscribe(breadcrumbs => {
this.setState({ breadcrumbs });
});
}
private unsubscribe() {
if (this.unsubscribable) {
this.unsubscribable.unsubscribe();
delete this.unsubscribable;
}
}
}

View file

@ -0,0 +1,50 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { NavControl, NavControlSide } from '../';
import { HeaderNavControl } from './header_nav_control';
describe('HeaderNavControl', () => {
const defaultNavControl = { name: '', order: 1, side: NavControlSide.Right };
it('calls navControl.render with div node', () => {
const renderSpy = jest.fn();
const navControl = { ...defaultNavControl, render: renderSpy } as NavControl;
mount(<HeaderNavControl navControl={navControl} />);
expect(renderSpy.mock.calls.length).toEqual(1);
const [divNode] = renderSpy.mock.calls[0];
expect(divNode).toBeInstanceOf(HTMLElement);
});
it('calls unrender callback when unmounted', () => {
const unrenderSpy = jest.fn();
const render = () => unrenderSpy;
const navControl = { ...defaultNavControl, render } as NavControl;
const wrapper = mount(<HeaderNavControl navControl={navControl} />);
wrapper.unmount();
expect(unrenderSpy.mock.calls.length).toEqual(1);
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 { NavControl } from '../';
interface Props {
navControl: NavControl;
}
export class HeaderNavControl extends React.Component<Props> {
private readonly ref = React.createRef<HTMLDivElement>();
private unrender?: () => void;
public componentDidMount() {
if (!this.ref.current) {
throw new Error('<NavControl /> mounted without ref');
}
this.unrender = this.props.navControl.render(this.ref.current) || undefined;
}
public componentDidUpdate(prevProps: Props) {
if (this.props.navControl.render === prevProps.navControl.render) {
return;
}
if (!this.ref.current) {
throw new Error('<NavControl /> updated without ref');
}
if (this.unrender) {
this.unrender();
}
this.unrender = this.props.navControl.render(this.ref.current) || undefined;
}
public componentWillUnmount() {
if (this.unrender) {
this.unrender();
}
}
public render() {
return <div ref={this.ref} />;
}
}

View file

@ -0,0 +1,50 @@
/*
* 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 {
// @ts-ignore
EuiHeaderSectionItem,
} from '@elastic/eui';
import { NavControl } from '../';
import { HeaderNavControl } from './header_nav_control';
interface Props {
navControls: NavControl[];
}
export class HeaderNavControls extends Component<Props> {
public render() {
const { navControls } = this.props;
if (!navControls) {
return null;
}
return navControls.map(this.renderNavControl);
}
private renderNavControl = (navControl: NavControl) => (
<EuiHeaderSectionItem key={navControl.name}>
<HeaderNavControl navControl={navControl} />
</EuiHeaderSectionItem>
);
}

View file

@ -0,0 +1,47 @@
/*
* 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 { uiModules } from '../../../modules';
import { Header } from './components/header';
import './header_global_nav.less';
import { chromeHeaderNavControlsRegistry } from 'ui/registry/chrome_header_nav_controls';
import { breadcrumbs } from '../../services/breadcrumb_state';
const module = uiModules.get('kibana');
module.directive('headerGlobalNav', (reactDirective, chrome, Private) => {
const navControls = Private(chromeHeaderNavControlsRegistry);
const navLinks = chrome.getNavLinks();
const homeHref = chrome.addBasePath('/app/kibana#/home');
return reactDirective(Header, [
// scope accepted by directive, passed in as React props
'appTitle',
'isVisible',
],
{},
// angular injected React props
{
breadcrumbs,
navLinks,
navControls,
homeHref
});
});

View file

@ -0,0 +1,11 @@
.header-global-wrapper {
width: 100%;
position: fixed;
top: 0;
z-index: 10;
}
.header-global-wrapper + .app-wrapper {
top: 65px;
left: 0;
}

View file

@ -0,0 +1,45 @@
/*
* 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 './header_global_nav';
export enum NavControlSide {
Left = 'left',
Right = 'right',
}
export interface NavControl {
name: string;
order: number;
side: NavControlSide;
render: (targetDomElement: HTMLDivElement) => (() => void) | void;
}
export interface NavLink {
title: string;
url: string;
id: string;
euiIconType: IconType;
}
export interface Breadcrumb {
text: string;
href?: string;
}

View file

@ -19,6 +19,7 @@
import './global_nav';
import './header_global_nav';
import { kbnChromeProvider } from './kbn_chrome';
import { kbnAppendChromeNavControls } from './append_nav_controls';

View file

@ -1,5 +1,8 @@
<div class="content" chrome-context data-test-subj="kibanaChrome">
<kbn-loading-indicator></kbn-loading-indicator>
<global-nav
ng-if="!k7design"
chrome="chrome"
data-test-subj="globalNav"
is-visible="chrome.getVisible()"
@ -8,6 +11,13 @@
app-title="chrome.getAppTitle()"
></global-nav>
<header-global-nav
ng-if="k7design"
class="header-global-wrapper"
is-visible="chrome.getVisible()"
app-title="chrome.getAppTitle()"
></header-global-nav>
<div class="app-wrapper" ng-class="{ 'hidden-chrome': !chrome.getVisible() }">
<div class="app-wrapper-panel">
<kbn-notifications
@ -16,8 +26,6 @@
<div id="globalBannerList"></div>
<kbn-loading-indicator></kbn-loading-indicator>
<div
class="application"
ng-class="'tab-' + chrome.getFirstPathSegment() + ' ' + chrome.getApplicationClasses()"

View file

@ -57,7 +57,9 @@ export function kbnChromeProvider(chrome, internals) {
},
controllerAs: 'chrome',
controller($scope, $rootScope, $location, $http, Private) {
controller($scope, $rootScope, $location, $http, Private, config) {
config.watch('k7design', (val) => $scope.k7design = val);
const getUnhashableStates = Private(getUnhashableStatesProvider);
// are we showing the embedded version of the chrome?

View file

@ -14,7 +14,7 @@
top: 0; // 1
left: 0; // 1
right: 0; // 1
z-index: 1; // 1
z-index: 20; // 1
overflow: hidden; // 2
height: @loadingIndicatorHeight;
@ -31,7 +31,7 @@
right: 0;
bottom: 0;
position: absolute;
z-index: 10;
z-index: 21;
visibility: visible;
display: block;
animation: animate-loading-indicator 2s linear infinite;

View file

@ -0,0 +1,62 @@
/*
* 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 { Subject, Subscribable } from 'rxjs';
// @ts-ignore
import { uiModules } from '../../modules';
import { Breadcrumb } from '../directives/header_global_nav';
// A flag used to keep track of clearing between route changes.
let shouldClear = false;
// Subject used by Header component to subscribe to breadcrumbs changes.
// This is not exposed publicly.
const breadcrumbsSubject = new Subject();
/**
* A rxjs subscribable that can be used to subscribe to breadcrumb updates.
*/
export const breadcrumbs: Subscribable<Breadcrumb[]> = breadcrumbsSubject;
/**
* Should be called by plugins to set breadcrumbs in the header navigation.
*
* @param breadcrumbs: Array<Breadcrumb> where Breadcrumb has shape
* { text: '', href?: '' }
*/
export const set = (newBreadcrumbs: Breadcrumb[]) => {
breadcrumbsSubject.next(newBreadcrumbs);
// If a plugin called set, don't clear on route change.
shouldClear = false;
};
uiModules.get('kibana').service('breadcrumbState', ($rootScope: any) => {
// When a route change happens we want to clear the breadcrumbs ONLY if
// the new route does not set any breadcrumbs. Deferring the clearing until
// the route finishes changing helps avoiding the breadcrumbs from 'flickering'.
$rootScope.$on('$routeChangeStart', () => (shouldClear = true));
$rootScope.$on('$routeChangeSuccess', () => {
if (shouldClear) {
set([]);
}
});
return { set };
});

View file

@ -18,3 +18,4 @@
*/
import './global_nav_state';
import './breadcrumb_state';

View file

@ -99,7 +99,11 @@ describe('IndexedArray', function () {
reg.push(firstUser);
// end up with the same structure that is in the users fixture
expect(reg.byGroup).to.eql(users.byGroup);
expect(Object.keys(reg.byGroup).length).to.be(Object.keys(users.byGroup).length);
for (const group of Object.keys(reg.byGroup)) {
expect(reg.byGroup[group].toJSON()).to.eql(users.byGroup[group]);
}
expect(reg.inIdOrder).to.eql(users.inIdOrder);
});

41
src/ui/public/indexed_array/index.d.ts vendored Normal file
View file

@ -0,0 +1,41 @@
/*
* 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 { ListIterator } from 'lodash';
interface IndexedArrayConfig<T> {
index?: string[];
group?: string[];
order?: string[];
initialSet?: T[];
immutable?: boolean;
}
declare class IndexedArray<T> extends Array<T> {
public immutable: boolean;
public raw: T[];
// May not actually be present, is dynamically defined.
public inOrder: T[];
constructor(config: IndexedArrayConfig<T>);
public remove(predicate: ListIterator<T, boolean>): T[];
public toJSON(): T[];
}

View file

@ -51,7 +51,7 @@ export class IndexedArray {
Object.defineProperty(this, 'raw', { value: [] });
this._indexNames = _.union(
this._setupIndex(config.group, inflectIndex, organizeBy),
this._setupIndex(config.group, inflectIndex, organizeByIndexedArray(config)),
this._setupIndex(config.index, inflectIndex, _.indexBy),
this._setupIndex(config.order, inflectOrder, (raw, pluckValue) => {
return [...raw].sort((itemA, itemB) => {
@ -195,3 +195,20 @@ export class IndexedArray {
// using traditional `extends Array` syntax doesn't work with babel
// See https://babeljs.io/docs/usage/caveats/
Object.setPrototypeOf(IndexedArray.prototype, Array.prototype);
// Similar to `organizeBy` but returns IndexedArrays instead of normal Arrays.
function organizeByIndexedArray(config) {
return (...args) => {
const grouped = organizeBy(...args);
return _.reduce(grouped, (acc, value, group) => {
acc[group] = new IndexedArray({
...config,
initialSet: value
});
return acc;
}, {});
};
}

View file

@ -3,6 +3,7 @@
data-test-subj="breadcrumbs"
role="heading"
aria-level="1"
ng-if="showPluginBreadcrumbs"
>
<div
class="kuiLocalBreadcrumb"

View file

@ -26,7 +26,6 @@ const module = uiModules.get('kibana');
module.directive('breadCrumbs', function () {
return {
restrict: 'E',
replace: true,
scope: {
omitCurrentPage: '=',
/**
@ -47,7 +46,8 @@ module.directive('breadCrumbs', function () {
useLinks: '='
},
template: breadCrumbsTemplate,
controller: function ($scope) {
controller: function ($scope, config, breadcrumbState) {
config.watch('k7design', (val) => $scope.showPluginBreadcrumbs = !val);
function omitPagesFilter(crumb) {
return (
@ -70,6 +70,15 @@ module.directive('breadCrumbs', function () {
.filter(omitPagesFilter)
.filter(omitCurrentPageFilter)
);
const newBreadcrumbs = $scope.breadcrumbs
.map(b => ({ text: b.display, href: b.href }));
if ($scope.pageTitle) {
newBreadcrumbs.push({ text: $scope.pageTitle });
}
breadcrumbState.set(newBreadcrumbs);
});
}
};

31
src/ui/public/registry/_registry.d.ts vendored Normal file
View file

@ -0,0 +1,31 @@
/*
* 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 { IndexedArray, IndexedArrayConfig } from '../indexed_array';
interface UIRegistry<T> extends IndexedArray<T> {
register<T>(privateModule: T): UIRegistry<T>;
}
interface UIRegistrySpec<T> extends IndexedArrayConfig<T> {
name: string;
filter?(item: T): boolean;
}
declare function uiRegistry<T>(spec: UIRegistrySpec<T>): UIRegistry<T>;

View file

@ -0,0 +1,37 @@
/*
* 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 { NavControl } from '../chrome/directives/header_global_nav';
import { IndexedArray } from '../indexed_array';
import { uiRegistry, UIRegistry } from './_registry';
interface BySideDictionary {
// this key should be from NavControlSide
[side: string]: IndexedArray<NavControl>;
}
export interface ChromeHeaderNavControlsRegistry extends UIRegistry<NavControl> {
bySide: BySideDictionary;
}
export const chromeHeaderNavControlsRegistry: ChromeHeaderNavControlsRegistry = uiRegistry({
name: 'chromeHeaderNavControls',
order: ['order'],
group: ['side'],
}) as ChromeHeaderNavControlsRegistry;

View file

@ -23,4 +23,3 @@ export const chromeNavControlsRegistry = uiRegistry({
name: 'chromeNavControls',
order: ['order']
});

View file

@ -29,6 +29,7 @@ export class UiApp {
order = 0,
description,
icon,
euiIconType,
hidden,
linkToLastSubUrl,
listed,
@ -45,6 +46,7 @@ export class UiApp {
this._order = order;
this._description = description;
this._icon = icon;
this._euiIconType = euiIconType;
this._linkToLastSubUrl = linkToLastSubUrl;
this._hidden = hidden;
this._listed = listed;
@ -66,6 +68,7 @@ export class UiApp {
order: this._order,
description: this._description,
icon: this._icon,
euiIconType: this._euiIconType,
url: this._url,
linkToLastSubUrl: this._linkToLastSubUrl
});
@ -117,6 +120,7 @@ export class UiApp {
title: this._title,
description: this._description,
icon: this._icon,
euiIconType: this._euiIconType,
main: this._main,
navLink: this._navLink,
linkToLastSubUrl: this._linkToLastSubUrl,

View file

@ -29,6 +29,7 @@ function applySpecDefaults(spec, type, pluginSpec) {
order = 0,
description = '',
icon,
euiIconType,
hidden = false,
linkToLastSubUrl = true,
listed = !hidden,
@ -55,6 +56,7 @@ function applySpecDefaults(spec, type, pluginSpec) {
order,
description,
icon,
euiIconType,
hidden,
linkToLastSubUrl,
listed,

View file

@ -31,6 +31,7 @@ describe('UiNavLink', () => {
url: '/app/kibana#/discover',
description: 'interactively explore your data',
icon: 'plugins/kibana/assets/discover.svg',
euiIconType: 'discoverApp',
hidden: true,
disabled: true
};
@ -44,6 +45,7 @@ describe('UiNavLink', () => {
subUrlBase: spec.url,
description: spec.description,
icon: spec.icon,
euiIconType: spec.euiIconType,
hidden: spec.hidden,
disabled: spec.disabled,

View file

@ -27,6 +27,7 @@ export class UiNavLink {
subUrlBase,
description,
icon,
euiIconType,
linkToLastSubUrl = true,
hidden = false,
disabled = false,
@ -40,6 +41,7 @@ export class UiNavLink {
this._subUrlBase = subUrlBase || url;
this._description = description;
this._icon = icon;
this._euiIconType = euiIconType;
this._linkToLastSubUrl = linkToLastSubUrl;
this._hidden = hidden;
this._disabled = disabled;
@ -59,6 +61,7 @@ export class UiNavLink {
subUrlBase: this._subUrlBase,
description: this._description,
icon: this._icon,
euiIconType: this._euiIconType,
linkToLastSubUrl: this._linkToLastSubUrl,
hidden: this._hidden,
disabled: this._disabled,

View file

@ -22,7 +22,8 @@ export function apm(kibana) {
title: 'APM',
description: 'APM for the Elastic Stack',
main: 'plugins/apm/index',
icon: 'plugins/apm/icon.svg'
icon: 'plugins/apm/icon.svg',
euiIconType: 'apmApp'
},
home: ['plugins/apm/register_feature'],
injectDefaultVars(server) {

View file

@ -9,12 +9,37 @@ import { withBreadcrumbs } from 'react-router-breadcrumbs-hoc';
import { toQuery } from '../../../utils/url';
import { routes } from './routeConfig';
import { flatten, capitalize } from 'lodash';
import { set } from 'ui/chrome/services/breadcrumb_state';
class Breadcrumbs extends React.Component {
updateHeaderBreadcrumbs() {
const { _g = '', kuery = '' } = toQuery(this.props.location.search);
const breadcrumbs = this.props.breadcrumbs.map(({ breadcrumb, match }) => ({
text: breadcrumb,
href: `#${match.url}?_g=${_g}&kuery=${kuery}`
}));
set(breadcrumbs);
}
componentDidMount() {
this.updateHeaderBreadcrumbs();
}
componentDidUpdate() {
this.updateHeaderBreadcrumbs();
}
render() {
const { breadcrumbs, location } = this.props;
const { breadcrumbs, location, showPluginBreadcrumbs } = this.props;
const { _g = '', kuery = '' } = toQuery(location.search);
// If we don't display plugin breadcrumbs, render null, but continue
// to push updates to header.
if (!showPluginBreadcrumbs) {
return null;
}
return (
<div className="kuiLocalBreadcrumbs">
{breadcrumbs.map(({ breadcrumb, path, match }, i) => {

View file

@ -36,7 +36,7 @@ jest.mock(
function expectBreadcrumbToMatchSnapshot(route) {
const wrapper = mount(
<MemoryRouter initialEntries={[`${route}?_g=myG&kuery=myKuery`]}>
<Breadcrumbs />
<Breadcrumbs showPluginBreadcrumbs={true} />
</MemoryRouter>
);
expect(
@ -74,4 +74,13 @@ describe('Breadcrumbs', () => {
'/:serviceName/transactions/request/my-transaction-name'
);
});
it('does not render breadcrumbs when showPluginBreadcrumbs = false', () => {
const wrapper = mount(
<MemoryRouter initialEntries={[`/?_g=myG&kuery=myKuery`]}>
<Breadcrumbs showPluginBreadcrumbs={false} />
</MemoryRouter>
);
expect(wrapper.find('.kuiLocalBreadcrumbs').exists()).toEqual(false);
});
});

View file

@ -31,9 +31,13 @@ chrome.setRootTemplate(template);
const store = configureStore();
initTimepicker(history, store.dispatch).then(() => {
const showPluginBreadcrumbs = !chrome
.getUiSettingsClient()
.get('k7design', false);
ReactDOM.render(
<Router history={history}>
<Breadcrumbs />
<Breadcrumbs showPluginBreadcrumbs={showPluginBreadcrumbs} />
</Router>,
document.getElementById('react-apm-breadcrumbs')
);

View file

@ -20,6 +20,7 @@ export function canvas(kibana) {
title: 'Canvas',
description: 'Data driven workpads',
icon: 'plugins/canvas/icon.svg',
euiIconType: 'canvasApp',
main: 'plugins/canvas/app',
},
styleSheetPaths: `${__dirname}/public/style/index.scss`,

View file

@ -62,12 +62,15 @@ export class Popover extends Component {
return button(handleClick);
};
const appWrapper = document.querySelector('.app-wrapper');
return (
<EuiPopover
{...rest}
button={wrappedButton(this.handleClick)}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
container={appWrapper}
>
{children({ closePopover: this.closePopover })}
</EuiPopover>

View file

@ -21,6 +21,7 @@ export function graph(kibana) {
title: 'Graph',
order: 9000,
icon: 'plugins/graph/icon.png',
euiIconType: 'graphApp',
description: 'Graph exploration',
main: 'plugins/graph/app',
},

View file

@ -37,6 +37,7 @@ export const ml = (kibana) => {
title: 'Machine Learning',
description: 'Machine Learning for the Elastic Stack',
icon: 'plugins/ml/ml.svg',
euiIconType: 'machineLearningApp',
main: 'plugins/ml/app',
},
hacks: ['plugins/ml/hacks/toggle_app_link_in_nav'],

View file

@ -1,7 +1,10 @@
<kbn-top-nav name="dashboard" config="topNavMenu">
<div data-transclude-slots>
<!-- Breadcrumbs. -->
<div data-transclude-slot="topLeftCorner" class="kuiLocalBreadcrumbs">
<div
data-transclude-slot="topLeftCorner"
ng-if="showPluginBreadcrumbs"
class="kuiLocalBreadcrumbs">
<div ng-repeat="crumb in breadcrumbs" class="kuiLocalBreadcrumb">
<a ng-if="crumb.url" kbn-href="{{ crumb.url }}" class="kuiLocalBreadcrumb__link">{{ crumb.label }}</a>
<span ng-if="!crumb.url" class="kuiLocalBreadcrumb__link">{{ crumb.label }}</span>

View file

@ -14,7 +14,7 @@ import uiRouter from 'ui/routes';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlNavMenu', function () {
module.directive('mlNavMenu', function (breadcrumbState, config) {
return {
restrict: 'E',
transclude: true,
@ -64,6 +64,9 @@ module.directive('mlNavMenu', function () {
});
scope.breadcrumbs = breadcrumbs.filter(Boolean);
config.watch('k7design', (val) => scope.showPluginBreadcrumbs = !val);
breadcrumbState.set(scope.breadcrumbs.map(b => ({ text: b.label, href: b.url })));
// when the page loads, focus on the first breadcrumb
el.ready(() => {
const $crumbs = $('.kuiLocalBreadcrumbs a');

View file

@ -7,20 +7,24 @@
Breadcrumbs can't be automatically derived because the directive doesn't
automatically know to show the Clusters breadcrumb. We recreate the
structure and styles manually -->
<div data-transclude-slot="topLeftCorner" class="kuiLocalBreadcrumbs">
<div ng-repeat="crumb in monitoringMain.breadcrumbs" class="kuiLocalBreadcrumb">
<a
ng-if="crumb.url"
kbn-href="{{ crumb.url }}"
class="kuiLocalBreadcrumb__link"
data-test-subj="{{ crumb.testSubj }}"
>
{{ crumb.label }}
</a>
<div data-transclude-slot="topLeftCorner">
<div
ng-if="showPluginBreadcrumbs"
class="kuiLocalBreadcrumbs">
<div ng-repeat="crumb in monitoringMain.breadcrumbs" class="kuiLocalBreadcrumb">
<a
ng-if="crumb.url"
kbn-href="{{ crumb.url }}"
class="kuiLocalBreadcrumb__link"
data-test-subj="{{ crumb.testSubj }}"
>
{{ crumb.label }}
</a>
<span ng-if="!crumb.url">
{{ crumb.label }}
</span>
<span ng-if="!crumb.url">
{{ crumb.label }}
</span>
</div>
</div>
</div>

View file

@ -71,7 +71,7 @@ export class MonitoringMainController {
}
const uiModule = uiModules.get('plugins/monitoring/directives', []);
uiModule.directive('monitoringMain', (breadcrumbs, license, kbnUrl) => {
uiModule.directive('monitoringMain', (breadcrumbs, license, kbnUrl, config) => {
return {
restrict: 'E',
transclude: true,
@ -80,6 +80,7 @@ uiModule.directive('monitoringMain', (breadcrumbs, license, kbnUrl) => {
controllerAs: 'monitoringMain',
bindToController: true,
link(scope, _element, attributes, controller) {
config.watch('k7design', (val) => scope.showPluginBreadcrumbs = !val);
controller.setup({
licenseService: license,

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { set as setBreadcrumbs } from 'ui/chrome/services/breadcrumb_state';
// Helper for making objects to use in a link element
const createCrumb = (url, label, testSubj) => {
const crumb = { url, label };
@ -118,6 +120,8 @@ export function breadcrumbsProvider() {
breadcrumbs = breadcrumbs.concat(getApmBreadcrumbs(mainInstance));
}
setBreadcrumbs(breadcrumbs.map(b => ({ text: b.label, href: b.url })));
return breadcrumbs;
};
}

View file

@ -16,6 +16,7 @@ export const uiExports = {
order: 9002,
description: 'Monitoring for Elastic Stack',
icon: 'plugins/monitoring/icons/monitoring.svg',
euiIconType: 'monitoringApp',
linkToLastSubUrl: false,
main: 'plugins/monitoring/monitoring',
},

View file

@ -4,15 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { constant } from 'lodash';
import { chromeNavControlsRegistry } from 'ui/registry/chrome_nav_controls';
import { uiModules } from 'ui/modules';
import { chromeNavControlsRegistry } from 'ui/registry/chrome_nav_controls';
import template from 'plugins/security/views/nav_control/nav_control.html';
import 'plugins/security/services/shield_user';
import '../account/account';
import { PathProvider } from 'plugins/xpack_main/services/path';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import { chromeHeaderNavControlsRegistry } from 'ui/registry/chrome_header_nav_controls';
import { SecurityNavControl } from './nav_control_component';
import { NavControlSide } from 'ui/chrome/directives/header_global_nav';
chromeNavControlsRegistry.register(constant({
name: 'security',
order: 1000,
@ -37,3 +45,27 @@ module.controller('securityNavController', ($scope, ShieldUser, globalNavState,
return tooltip;
};
});
chromeHeaderNavControlsRegistry.register((ShieldUser, kbnBaseUrl, Private) => ({
name: 'security',
order: 1000,
side: NavControlSide.Right,
render(el) {
const xpackInfo = Private(XPackInfoProvider);
const showSecurityLinks = xpackInfo.get('features.security.showLinks');
if (Private(PathProvider).isLoginOrLogout() || !showSecurityLinks) return null;
const props = {
user: ShieldUser.getCurrent(),
route: `${kbnBaseUrl}#/account`,
};
props.user.$promise.then(() => {
// Wait for the user to be propogated before rendering into the DOM.
ReactDOM.render(<SecurityNavControl {...props} />, el);
});
return () => ReactDOM.unmountComponentAtNode(el);
}
}));

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, {
Component,
} from 'react';
import {
EuiAvatar,
EuiFlexGroup,
EuiFlexItem,
EuiHeaderSectionItemButton,
EuiLink,
EuiText,
EuiSpacer,
EuiPopover,
} from '@elastic/eui';
/**
* Placeholder for now from eui demo. Will need to be populated by Security plugin
*/
export class SecurityNavControl extends Component {
constructor(props) {
super(props);
this.state = {
isOpen: false,
};
}
onMenuButtonClick = () => {
this.setState({
isOpen: !this.state.isOpen,
});
};
closeMenu = () => {
this.setState({
isOpen: false,
});
};
render() {
const { user, route } = this.props;
const name = user.full_name || user.username || '';
const button = (
<EuiHeaderSectionItemButton
aria-controls="headerUserMenu"
aria-expanded={this.state.isOpen}
aria-haspopup="true"
aria-label="Account menu"
onClick={this.onMenuButtonClick}
>
<EuiAvatar name={name} size="s" />
</EuiHeaderSectionItemButton>
);
return (
<EuiPopover
id="headerUserMenu"
ownFocus
button={button}
isOpen={this.state.isOpen}
anchorPosition="downRight"
closePopover={this.closeMenu}
panelPaddingSize="none"
>
<div style={{ width: 320 }}>
<EuiFlexGroup gutterSize="m" className="euiHeaderProfile" responsive={false}>
<EuiFlexItem grow={false}>
<EuiAvatar name={name} size="xl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<p>{name}</p>
</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiLink href={route}>Edit profile</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href="/logout">Log out</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</EuiPopover>
);
}
}