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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,7 +81,17 @@ app.directive('dashboardApp', function ($injector) {
return { return {
restrict: 'E', restrict: 'E',
controllerAs: 'dashboardApp', 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 filterManager = Private(FilterManagerProvider);
const filterBar = Private(FilterBarQueryFilterProvider); const filterBar = Private(FilterBarQueryFilterProvider);
const docTitle = Private(DocTitleProvider); const docTitle = Private(DocTitleProvider);
@ -169,6 +179,18 @@ app.directive('dashboardApp', function ($injector) {
dashboardStateManager.getTitle(), dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(), dashboardStateManager.getViewMode(),
dashboardStateManager.getIsDirty(timefilter)); 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.newDashboard = () => { kbnUrl.change(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}); };
$scope.saveState = () => dashboardStateManager.saveState(); $scope.saveState = () => dashboardStateManager.saveState();
$scope.getShouldShowEditHelp = () => ( $scope.getShouldShowEditHelp = () => (

View file

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

View file

@ -155,8 +155,8 @@ function discoverController(
courier, courier,
kbnUrl, kbnUrl,
localStorage, localStorage,
breadcrumbState
) { ) {
const Vis = Private(VisProvider); const Vis = Private(VisProvider);
const docTitle = Private(DocTitleProvider); const docTitle = Private(DocTitleProvider);
const HitSortFn = Private(PluginsKibanaDiscoverHitSortFnProvider); const HitSortFn = Private(PluginsKibanaDiscoverHitSortFnProvider);
@ -289,6 +289,12 @@ function discoverController(
const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : '';
docTitle.change(`Discover${pageTitleSuffix}`); 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; let stateMonitor;
const $state = $scope.state = new AppState(getStateDefaults()); const $state = $scope.state = new AppState(getStateDefaults());

View file

@ -5,11 +5,14 @@
<!-- Title. --> <!-- Title. -->
<div <div
data-transclude-slot="topLeftCorner" 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>
</div> </div>
</kbn-top-nav> </kbn-top-nav>

View file

@ -34,6 +34,7 @@ export function VisualizeListingController($injector) {
const Notifier = $injector.get('Notifier'); const Notifier = $injector.get('Notifier');
const Private = $injector.get('Private'); const Private = $injector.get('Private');
const config = $injector.get('config'); const config = $injector.get('config');
const breadcrumbState = $injector.get('breadcrumbState');
timefilter.disableAutoRefreshSelector(); timefilter.disableAutoRefreshSelector();
timefilter.disableTimeRangeSelector(); timefilter.disableTimeRangeSelector();
@ -58,4 +59,7 @@ export function VisualizeListingController($injector) {
return visualizationService.delete(selectedIds) return visualizationService.delete(selectedIds)
.catch(error => notify.error(error)); .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 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`, 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': { 'search:queryLanguage': {
name: 'Query language', name: 'Query language',
value: 'lucene', value: 'lucene',

View file

@ -26,6 +26,7 @@ export default function (kibana) {
order: -1000, order: -1000,
description: 'Time series expressions for everything', description: 'Time series expressions for everything',
icon: 'plugins/timelion/icon.svg', icon: 'plugins/timelion/icon.svg',
euiIconType: 'timelionApp',
main: 'plugins/timelion/app', main: 'plugins/timelion/app',
}, },
styleSheetPaths: `${__dirname}/public/index.scss`, 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 './global_nav';
import './header_global_nav';
import { kbnChromeProvider } from './kbn_chrome'; import { kbnChromeProvider } from './kbn_chrome';
import { kbnAppendChromeNavControls } from './append_nav_controls'; import { kbnAppendChromeNavControls } from './append_nav_controls';

View file

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

View file

@ -57,7 +57,9 @@ export function kbnChromeProvider(chrome, internals) {
}, },
controllerAs: 'chrome', 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); const getUnhashableStates = Private(getUnhashableStatesProvider);
// are we showing the embedded version of the chrome? // are we showing the embedded version of the chrome?

View file

@ -14,7 +14,7 @@
top: 0; // 1 top: 0; // 1
left: 0; // 1 left: 0; // 1
right: 0; // 1 right: 0; // 1
z-index: 1; // 1 z-index: 20; // 1
overflow: hidden; // 2 overflow: hidden; // 2
height: @loadingIndicatorHeight; height: @loadingIndicatorHeight;
@ -31,7 +31,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
position: absolute; position: absolute;
z-index: 10; z-index: 21;
visibility: visible; visibility: visible;
display: block; display: block;
animation: animate-loading-indicator 2s linear infinite; 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 './global_nav_state';
import './breadcrumb_state';

View file

@ -99,7 +99,11 @@ describe('IndexedArray', function () {
reg.push(firstUser); reg.push(firstUser);
// end up with the same structure that is in the users fixture // 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); 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: [] }); Object.defineProperty(this, 'raw', { value: [] });
this._indexNames = _.union( 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.index, inflectIndex, _.indexBy),
this._setupIndex(config.order, inflectOrder, (raw, pluckValue) => { this._setupIndex(config.order, inflectOrder, (raw, pluckValue) => {
return [...raw].sort((itemA, itemB) => { return [...raw].sort((itemA, itemB) => {
@ -195,3 +195,20 @@ export class IndexedArray {
// using traditional `extends Array` syntax doesn't work with babel // using traditional `extends Array` syntax doesn't work with babel
// See https://babeljs.io/docs/usage/caveats/ // See https://babeljs.io/docs/usage/caveats/
Object.setPrototypeOf(IndexedArray.prototype, Array.prototype); 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" data-test-subj="breadcrumbs"
role="heading" role="heading"
aria-level="1" aria-level="1"
ng-if="showPluginBreadcrumbs"
> >
<div <div
class="kuiLocalBreadcrumb" class="kuiLocalBreadcrumb"

View file

@ -26,7 +26,6 @@ const module = uiModules.get('kibana');
module.directive('breadCrumbs', function () { module.directive('breadCrumbs', function () {
return { return {
restrict: 'E', restrict: 'E',
replace: true,
scope: { scope: {
omitCurrentPage: '=', omitCurrentPage: '=',
/** /**
@ -47,7 +46,8 @@ module.directive('breadCrumbs', function () {
useLinks: '=' useLinks: '='
}, },
template: breadCrumbsTemplate, template: breadCrumbsTemplate,
controller: function ($scope) { controller: function ($scope, config, breadcrumbState) {
config.watch('k7design', (val) => $scope.showPluginBreadcrumbs = !val);
function omitPagesFilter(crumb) { function omitPagesFilter(crumb) {
return ( return (
@ -70,6 +70,15 @@ module.directive('breadCrumbs', function () {
.filter(omitPagesFilter) .filter(omitPagesFilter)
.filter(omitCurrentPageFilter) .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', name: 'chromeNavControls',
order: ['order'] order: ['order']
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,12 +9,37 @@ import { withBreadcrumbs } from 'react-router-breadcrumbs-hoc';
import { toQuery } from '../../../utils/url'; import { toQuery } from '../../../utils/url';
import { routes } from './routeConfig'; import { routes } from './routeConfig';
import { flatten, capitalize } from 'lodash'; import { flatten, capitalize } from 'lodash';
import { set } from 'ui/chrome/services/breadcrumb_state';
class Breadcrumbs extends React.Component { 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() { render() {
const { breadcrumbs, location } = this.props; const { breadcrumbs, location, showPluginBreadcrumbs } = this.props;
const { _g = '', kuery = '' } = toQuery(location.search); 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 ( return (
<div className="kuiLocalBreadcrumbs"> <div className="kuiLocalBreadcrumbs">
{breadcrumbs.map(({ breadcrumb, path, match }, i) => { {breadcrumbs.map(({ breadcrumb, path, match }, i) => {

View file

@ -36,7 +36,7 @@ jest.mock(
function expectBreadcrumbToMatchSnapshot(route) { function expectBreadcrumbToMatchSnapshot(route) {
const wrapper = mount( const wrapper = mount(
<MemoryRouter initialEntries={[`${route}?_g=myG&kuery=myKuery`]}> <MemoryRouter initialEntries={[`${route}?_g=myG&kuery=myKuery`]}>
<Breadcrumbs /> <Breadcrumbs showPluginBreadcrumbs={true} />
</MemoryRouter> </MemoryRouter>
); );
expect( expect(
@ -74,4 +74,13 @@ describe('Breadcrumbs', () => {
'/:serviceName/transactions/request/my-transaction-name' '/: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(); const store = configureStore();
initTimepicker(history, store.dispatch).then(() => { initTimepicker(history, store.dispatch).then(() => {
const showPluginBreadcrumbs = !chrome
.getUiSettingsClient()
.get('k7design', false);
ReactDOM.render( ReactDOM.render(
<Router history={history}> <Router history={history}>
<Breadcrumbs /> <Breadcrumbs showPluginBreadcrumbs={showPluginBreadcrumbs} />
</Router>, </Router>,
document.getElementById('react-apm-breadcrumbs') document.getElementById('react-apm-breadcrumbs')
); );

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,10 @@
<kbn-top-nav name="dashboard" config="topNavMenu"> <kbn-top-nav name="dashboard" config="topNavMenu">
<div data-transclude-slots> <div data-transclude-slots>
<!-- Breadcrumbs. --> <!-- 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"> <div ng-repeat="crumb in breadcrumbs" class="kuiLocalBreadcrumb">
<a ng-if="crumb.url" kbn-href="{{ crumb.url }}" class="kuiLocalBreadcrumb__link">{{ crumb.label }}</a> <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> <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'; import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml'); const module = uiModules.get('apps/ml');
module.directive('mlNavMenu', function () { module.directive('mlNavMenu', function (breadcrumbState, config) {
return { return {
restrict: 'E', restrict: 'E',
transclude: true, transclude: true,
@ -64,6 +64,9 @@ module.directive('mlNavMenu', function () {
}); });
scope.breadcrumbs = breadcrumbs.filter(Boolean); 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 // when the page loads, focus on the first breadcrumb
el.ready(() => { el.ready(() => {
const $crumbs = $('.kuiLocalBreadcrumbs a'); const $crumbs = $('.kuiLocalBreadcrumbs a');

View file

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

View file

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

View file

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

View file

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

View file

@ -4,15 +4,23 @@
* you may not use this file except in compliance with the Elastic License. * 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 { constant } from 'lodash';
import { chromeNavControlsRegistry } from 'ui/registry/chrome_nav_controls';
import { uiModules } from 'ui/modules'; 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 template from 'plugins/security/views/nav_control/nav_control.html';
import 'plugins/security/services/shield_user'; import 'plugins/security/services/shield_user';
import '../account/account'; import '../account/account';
import { PathProvider } from 'plugins/xpack_main/services/path'; import { PathProvider } from 'plugins/xpack_main/services/path';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; 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({ chromeNavControlsRegistry.register(constant({
name: 'security', name: 'security',
order: 1000, order: 1000,
@ -37,3 +45,27 @@ module.controller('securityNavController', ($scope, ShieldUser, globalNavState,
return tooltip; 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>
);
}
}