[Enterprise Search] Add solution-level side navigation (#74705)

* Add basic layout/sidebar blocking

- note: we should *not* set left: 0 / top: 0 etc., as this can interfere with Kibana's existing UI (e.g. docked navigation, telemetry callout)

* Add sidebar styles + static links

* Refactor SideNav to be a reusable component

- So that Workplace Search can reuse the same layout but pass in their own custom nav
+ Refactor AppSearch to use Layout in router

* Refactor all views to account for in-router Layout

* Fix root redirects not working as expected

- If enterpriseSearchUrl hasn't been set, all pages should redirect to SetupGuide, not just root
- The engines redirect simply wasn't working at all - it would always show a blank page when '/' was clicked in the Kibana breadcrumbs. Not sure if this is a Kibana issue - had to change to a component load to fix
+ Simplify index.test.tsx (probably unreasonable and not super helpful to add assertions for each new route)

* Implement active styling for links

* Fix failing location tests

- By adding a new useLocation mock
+ add SideNavLink active class test

TODO: I should probably rename react_router_history.mock to just react_router.mock

* Add responsive toggle + styling

* Add navigation accessibility attributes/controls

* [Feedback] Update mobile UX to close menu on link click/navigation

- This requires updating our EUI/React Router components to accept and run custom onClick events
- Also requires adding a new ReactContext to pass down closeNavigation, but that's not too onerous thanks to useContext
This commit is contained in:
Constance 2020-08-12 09:33:10 -07:00 committed by GitHub
parent 8f54198434
commit 6ee2460ebc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 738 additions and 170 deletions

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { mockHistory } from './react_router_history.mock';
export { mockHistory, mockLocation } from './react_router_history.mock';
export { mockKibanaContext } from './kibana_context.mock';
export { mockLicenseContext } from './license_context.mock';
export {

View file

@ -5,7 +5,7 @@
*/
/**
* NOTE: This variable name MUST start with 'mock*' in order for
* NOTE: These variable names MUST start with 'mock*' in order for
* Jest to accept its use within a jest.mock()
*/
export const mockHistory = {
@ -15,9 +15,17 @@ export const mockHistory = {
pathname: '/current-path',
},
};
export const mockLocation = {
key: 'someKey',
pathname: '/current-path',
search: '?query=something',
hash: '#hash',
state: {},
};
jest.mock('react-router-dom', () => ({
useHistory: jest.fn(() => mockHistory),
useLocation: jest.fn(() => mockLocation),
}));
/**

View file

@ -5,7 +5,7 @@
*/
import React, { useContext } from 'react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { sendTelemetry } from '../../../shared/telemetry';
@ -32,43 +32,40 @@ export const EmptyState: React.FC = () => {
};
return (
<EuiPage restrictWidth>
<>
<SetPageChrome isRoot />
<EuiPageBody>
<EngineOverviewHeader />
<EuiPageContent className="emptyState">
<EuiEmptyPrompt
className="emptyState__prompt"
iconType="eyeClosed"
title={
<h2>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.emptyState.title"
defaultMessage="Create your first engine"
/>
</h2>
}
titleSize="l"
body={
<p>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.emptyState.description1"
defaultMessage="An App Search engine stores the documents for your search experience."
/>
</p>
}
actions={
<EuiButton iconType="popout" fill {...buttonProps}>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta"
defaultMessage="Create an engine"
/>
</EuiButton>
}
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
<EngineOverviewHeader />
<EuiPageContent className="emptyState">
<EuiEmptyPrompt
className="emptyState__prompt"
iconType="eyeClosed"
title={
<h2>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.emptyState.title"
defaultMessage="Create your first engine"
/>
</h2>
}
titleSize="l"
body={
<p>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.emptyState.description1"
defaultMessage="An App Search engine stores the documents for your search experience."
/>
</p>
}
actions={
<EuiButton iconType="popout" fill {...buttonProps}>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta"
defaultMessage="Create an engine"
/>
</EuiButton>
}
/>
</EuiPageContent>
</>
);
};

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui';
import { EuiPageContent } from '@elastic/eui';
import { ErrorStatePrompt } from '../../../shared/error_state';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
@ -16,16 +16,14 @@ import './empty_states.scss';
export const ErrorState: React.FC = () => {
return (
<EuiPage restrictWidth>
<>
<SetPageChrome isRoot />
<SendTelemetry action="error" metric="cannot_connect" />
<EuiPageBody>
<EngineOverviewHeader />
<EuiPageContent>
<ErrorStatePrompt />
</EuiPageContent>
</EuiPageBody>
</EuiPage>
<EngineOverviewHeader />
<EuiPageContent>
<ErrorStatePrompt />
</EuiPageContent>
</>
);
};

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui';
import { EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { EngineOverviewHeader } from '../engine_overview_header';
@ -14,17 +14,14 @@ import './empty_states.scss';
export const LoadingState: React.FC = () => {
return (
<EuiPage restrictWidth>
<>
<SetPageChrome isRoot />
<EuiPageBody>
<EngineOverviewHeader />
<EuiPageContent className="emptyState">
<EuiLoadingContent lines={5} />
<EuiSpacer size="xxl" />
<EuiLoadingContent lines={4} />
</EuiPageContent>
</EuiPageBody>
</EuiPage>
<EngineOverviewHeader />
<EuiPageContent className="emptyState">
<EuiLoadingContent lines={5} />
<EuiSpacer size="xxl" />
<EuiLoadingContent lines={4} />
</EuiPageContent>
</>
);
};

View file

@ -4,18 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Engine Overview
*/
.engineOverview {
width: 100%;
padding: $euiSize;
&__body {
padding: $euiSize;
@include euiBreakpoint('m', 'l', 'xl') {
padding: $euiSizeXL;
}
@include euiBreakpoint('m', 'l', 'xl') {
padding: $euiSizeXL;
}
}

View file

@ -6,8 +6,6 @@
import React, { useContext, useEffect, useState } from 'react';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentHeader,
EuiPageContentBody,
@ -92,64 +90,61 @@ export const EngineOverview: React.FC = () => {
if (!engines.length) return <EmptyState />;
return (
<EuiPage restrictWidth className="engineOverview">
<>
<SetPageChrome isRoot />
<SendTelemetry action="viewed" metric="engines_overview" />
<EuiPageBody>
<EngineOverviewHeader />
<EngineOverviewHeader />
<EuiPageContent panelPaddingSize="s" className="engineOverview">
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>
<img src={EnginesIcon} alt="" className="engineIcon" />
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.enginesOverview.engines"
defaultMessage="Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody data-test-subj="appSearchEngines">
<EngineTable
data={engines}
pagination={{
totalEngines: enginesTotal,
pageIndex: enginesPage - 1,
onPaginate: setEnginesPage,
}}
/>
</EuiPageContentBody>
<EuiPageContent panelPaddingSize="s" className="engineOverview__body">
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>
<img src={EnginesIcon} alt="" className="engineIcon" />
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.enginesOverview.engines"
defaultMessage="Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody data-test-subj="appSearchEngines">
<EngineTable
data={engines}
pagination={{
totalEngines: enginesTotal,
pageIndex: enginesPage - 1,
onPaginate: setEnginesPage,
}}
/>
</EuiPageContentBody>
{metaEngines.length > 0 && (
<>
<EuiSpacer size="xl" />
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>
<img src={MetaEnginesIcon} alt="" className="engineIcon" />
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines"
defaultMessage="Meta Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody data-test-subj="appSearchMetaEngines">
<EngineTable
data={metaEngines}
pagination={{
totalEngines: metaEnginesTotal,
pageIndex: metaEnginesPage - 1,
onPaginate: setMetaEnginesPage,
}}
/>
</EuiPageContentBody>
</>
)}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
{metaEngines.length > 0 && (
<>
<EuiSpacer size="xl" />
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>
<img src={MetaEnginesIcon} alt="" className="engineIcon" />
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines"
defaultMessage="Meta Engines"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody data-test-subj="appSearchMetaEngines">
<EngineTable
data={metaEngines}
pagination={{
totalEngines: metaEnginesTotal,
pageIndex: metaEnginesPage - 1,
onPaginate: setMetaEnginesPage,
}}
/>
</EuiPageContentBody>
</>
)}
</EuiPageContent>
</>
);
};

View file

@ -10,37 +10,33 @@ import React, { useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { shallow } from 'enzyme';
import { SetupGuide } from './components/setup_guide';
import { EngineOverview } from './components/engine_overview';
import { Layout, SideNav, SideNavLink } from '../shared/layout';
import { AppSearch, AppSearchNav } from './';
import { AppSearch } from './';
describe('AppSearch', () => {
it('renders', () => {
const wrapper = shallow(<AppSearch />);
describe('App Search Routes', () => {
describe('/', () => {
it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' }));
const wrapper = shallow(<AppSearch />);
expect(wrapper.find(Redirect)).toHaveLength(1);
expect(wrapper.find(EngineOverview)).toHaveLength(0);
});
it('renders Engine Overview when enterpriseSearchUrl is set', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({
enterpriseSearchUrl: 'https://foo.bar',
}));
const wrapper = shallow(<AppSearch />);
expect(wrapper.find(EngineOverview)).toHaveLength(1);
expect(wrapper.find(Redirect)).toHaveLength(0);
});
expect(wrapper.find(Layout)).toHaveLength(1);
});
describe('/setup_guide', () => {
it('renders', () => {
const wrapper = shallow(<AppSearch />);
it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => {
(useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' }));
const wrapper = shallow(<AppSearch />);
expect(wrapper.find(SetupGuide)).toHaveLength(1);
});
expect(wrapper.find(Redirect)).toHaveLength(1);
expect(wrapper.find(Layout)).toHaveLength(0);
});
});
describe('AppSearchNav', () => {
it('renders', () => {
const wrapper = shallow(<AppSearchNav />);
expect(wrapper.find(SideNav)).toHaveLength(1);
expect(wrapper.find(SideNavLink).first().prop('to')).toEqual('/engines');
expect(wrapper.find(SideNavLink).last().prop('to')).toEqual(
'http://localhost:3002/as#/role-mappings'
);
});
});

View file

@ -5,24 +5,80 @@
*/
import React, { useContext } from 'react';
import { Route, Redirect } from 'react-router-dom';
import { Route, Redirect, Switch } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { APP_SEARCH_PLUGIN } from '../../../common/constants';
import { KibanaContext, IKibanaContext } from '../index';
import { Layout, SideNav, SideNavLink } from '../shared/layout';
import { SetupGuide } from './components/setup_guide';
import { EngineOverview } from './components/engine_overview';
export const AppSearch: React.FC = () => {
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
if (!enterpriseSearchUrl)
return (
<Switch>
<Route exact path="/setup_guide">
<SetupGuide />
</Route>
<Route>
<Redirect to="/setup_guide" />
<SetupGuide /> {/* Kibana displays a blank page on redirect if this isn't included */}
</Route>
</Switch>
);
return (
<>
<Route exact path="/">
{!enterpriseSearchUrl ? <Redirect to="/setup_guide" /> : <EngineOverview />}
</Route>
<Route path="/setup_guide">
<Switch>
<Route exact path="/setup_guide">
<SetupGuide />
</Route>
</>
<Route>
<Layout navigation={<AppSearchNav />}>
<Switch>
<Route exact path="/">
{/* For some reason a Redirect to /engines just doesn't work here - it shows a blank page */}
<EngineOverview />
</Route>
<Route exact path="/engines">
<EngineOverview />
</Route>
</Switch>
</Layout>
</Route>
</Switch>
);
};
export const AppSearchNav: React.FC = () => {
const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
const externalUrl = `${enterpriseSearchUrl}/as#`;
return (
<SideNav product={APP_SEARCH_PLUGIN}>
<SideNavLink to="/engines" isRoot>
{i18n.translate('xpack.enterpriseSearch.appSearch.nav.engines', {
defaultMessage: 'Engines',
})}
</SideNavLink>
<SideNavLink isExternal to={`${externalUrl}/settings/account`}>
{i18n.translate('xpack.enterpriseSearch.appSearch.nav.settings', {
defaultMessage: 'Account Settings',
})}
</SideNavLink>
<SideNavLink isExternal to={`${externalUrl}/credentials`}>
{i18n.translate('xpack.enterpriseSearch.appSearch.nav.credentials', {
defaultMessage: 'Credentials',
})}
</SideNavLink>
<SideNavLink isExternal to={`${externalUrl}/role-mappings`}>
{i18n.translate('xpack.enterpriseSearch.appSearch.nav.roleMappings', {
defaultMessage: 'Role Mappings',
})}
</SideNavLink>
</SideNav>
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { Layout } from './layout';
export { SideNav, SideNavLink } from './side_nav';

View file

@ -0,0 +1,81 @@
/*
* 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.
*/
.enterpriseSearchLayout {
$sideBarWidth: $euiSize * 15;
$sideBarMobileHeight: $euiSize * 4.75;
display: block;
position: relative;
left: $sideBarWidth;
width: calc(100% - #{$sideBarWidth});
padding: 0;
@include euiBreakpoint('xs', 's', 'm') {
left: auto;
width: 100%;
}
&__sideBarToggle {
display: none;
@include euiBreakpoint('xs', 's', 'm') {
display: block;
position: absolute;
right: $euiSize;
top: $sideBarMobileHeight / 2;
transform: translateY(-50%) scale(0.9);
.euiButton {
min-width: 0;
}
}
}
&__sideBar {
z-index: $euiZNavigation;
position: fixed;
margin-left: -1 * $sideBarWidth;
margin-right: 0;
overflow-y: auto;
overflow-x: hidden;
$kibanaHeader: 49px; // NOTE: Keep an eye on this for changes
height: calc(100vh - #{$kibanaHeader});
width: $sideBarWidth;
background-color: $euiColorLightestShade;
box-shadow: inset (-1 * $euiSizeXS) 0 $euiSizeS (-1 * $euiSizeXS) rgba($euiShadowColor, 0.25);
@include euiBreakpoint('xs', 's', 'm') {
position: relative;
width: 100%;
height: $sideBarMobileHeight;
margin-left: 0;
overflow-y: hidden;
border-bottom: $euiBorderThin;
box-shadow: none;
&--isOpen {
height: auto;
overflow-y: auto;
}
}
}
&__body {
padding: $euiSizeXXL;
@include euiBreakpoint('m') {
padding: $euiSizeL;
}
@include euiBreakpoint('xs', 's') {
padding: $euiSize;
}
}
}

View file

@ -0,0 +1,61 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { EuiPageSideBar, EuiButton } from '@elastic/eui';
import { Layout, INavContext } from './layout';
describe('Layout', () => {
it('renders', () => {
const wrapper = shallow(<Layout navigation={null} />);
expect(wrapper.find('.enterpriseSearchLayout')).toHaveLength(1);
});
it('renders navigation', () => {
const wrapper = shallow(<Layout navigation={<nav className="nav-test">Hello World</nav>} />);
expect(wrapper.find('.enterpriseSearchLayout__sideBar')).toHaveLength(1);
expect(wrapper.find('.nav-test')).toHaveLength(1);
});
it('renders navigation toggle state', () => {
const wrapper = shallow(<Layout navigation={<nav className="nav-test">Hello World</nav>} />);
expect(wrapper.find(EuiPageSideBar).prop('className')).not.toContain('--isOpen');
expect(wrapper.find(EuiButton).prop('iconType')).toEqual('arrowRight');
const toggle = wrapper.find('[data-test-subj="enterpriseSearchNavToggle"]');
toggle.simulate('click');
expect(wrapper.find(EuiPageSideBar).prop('className')).toContain('--isOpen');
expect(wrapper.find(EuiButton).prop('iconType')).toEqual('arrowDown');
});
it('passes down NavContext to navigation links', () => {
const wrapper = shallow(<Layout navigation={<nav />} />);
const toggle = wrapper.find('[data-test-subj="enterpriseSearchNavToggle"]');
toggle.simulate('click');
expect(wrapper.find(EuiPageSideBar).prop('className')).toContain('--isOpen');
const context = (wrapper.find('ContextProvider').prop('value') as unknown) as INavContext;
context.closeNavigation();
expect(wrapper.find(EuiPageSideBar).prop('className')).not.toContain('--isOpen');
});
it('renders children', () => {
const wrapper = shallow(
<Layout navigation={null}>
<div className="testing">Test</div>
</Layout>
);
expect(wrapper.find('.enterpriseSearchLayout__body')).toHaveLength(1);
expect(wrapper.find('.testing')).toHaveLength(1);
});
});

View file

@ -0,0 +1,60 @@
/*
* 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, { useState } from 'react';
import classNames from 'classnames';
import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import './layout.scss';
interface ILayoutProps {
navigation: React.ReactNode;
}
export interface INavContext {
closeNavigation(): void;
}
export const NavContext = React.createContext({});
export const Layout: React.FC<ILayoutProps> = ({ children, navigation }) => {
const [isNavOpen, setIsNavOpen] = useState(false);
const toggleNavigation = () => setIsNavOpen(!isNavOpen);
const closeNavigation = () => setIsNavOpen(false);
const navClasses = classNames('enterpriseSearchLayout__sideBar', {
'enterpriseSearchLayout__sideBar--isOpen': isNavOpen, // eslint-disable-line @typescript-eslint/naming-convention
});
return (
<EuiPage className="enterpriseSearchLayout">
<EuiPageSideBar className={navClasses}>
<div className="enterpriseSearchLayout__sideBarToggle">
<EuiButton
size="s"
iconType={isNavOpen ? 'arrowDown' : 'arrowRight'}
iconSide="right"
aria-label={i18n.translate('xpack.enterpriseSearch.nav.toggleMenu', {
defaultMessage: 'Toggle secondary navigation',
})}
aria-expanded={isNavOpen}
aria-pressed={isNavOpen}
aria-controls="enterpriseSearchNav"
onClick={toggleNavigation}
data-test-subj="enterpriseSearchNavToggle"
>
{i18n.translate('xpack.enterpriseSearch.nav.menu', {
defaultMessage: 'Menu',
})}
</EuiButton>
</div>
<NavContext.Provider value={{ closeNavigation }}>{navigation}</NavContext.Provider>
</EuiPageSideBar>
<EuiPageBody className="enterpriseSearchLayout__body">{children}</EuiPageBody>
</EuiPage>
);
};

View file

@ -0,0 +1,75 @@
/*
* 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.
*/
$euiSizeML: $euiSize * 1.25; // 20px - between medium and large ¯\_()_/¯
.enterpriseSearchProduct {
display: flex;
align-items: center;
padding: $euiSizeML;
background-image: url('./side_nav_bg.svg');
background-repeat: no-repeat;
@include euiBreakpoint('xs', 's', 'm') {
padding: $euiSize $euiSizeML;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: $euiSizeXXL;
height: $euiSizeXXL;
margin-right: $euiSizeS;
background-color: $euiColorEmptyShade;
border-radius: 50%;
@include euiSlightShadow();
.euiIcon {
width: $euiSizeML;
height: $euiSizeML;
}
}
&__title {
.euiText {
font-weight: $euiFontWeightMedium;
}
}
}
.enterpriseSearchNavLinks {
&__item {
display: block;
padding: $euiSizeM $euiSizeML;
font-size: $euiFontSizeS;
font-weight: $euiFontWeightMedium;
line-height: $euiFontSizeM;
$activeBgColor: rgba($euiColorFullShade, 0.05);
&--isActive {
background-color: $activeBgColor;
}
&.euiLink {
color: $euiTextColor;
font-weight: $euiFontWeightMedium;
&:hover {
color: $euiTextColor;
}
&:focus {
outline: solid 0 $activeBgColor;
background-color: $activeBgColor;
}
}
}
}

View file

@ -0,0 +1,92 @@
/*
* 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 '../../__mocks__/react_router_history.mock';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { shallow } from 'enzyme';
import { EuiLink as EuiLinkExternal } from '@elastic/eui';
import { EuiLink } from '../react_router_helpers';
import { ENTERPRISE_SEARCH_PLUGIN, APP_SEARCH_PLUGIN } from '../../../../common/constants';
import { SideNav, SideNavLink } from './';
describe('SideNav', () => {
it('renders link children', () => {
const wrapper = shallow(
<SideNav product={ENTERPRISE_SEARCH_PLUGIN}>
<div className="testing">Hello World</div>
</SideNav>
);
expect(wrapper.type()).toEqual('nav');
expect(wrapper.find('.enterpriseSearchNavLinks')).toHaveLength(1);
expect(wrapper.find('.testing')).toHaveLength(1);
});
it('renders a custom product', () => {
const wrapper = shallow(<SideNav product={APP_SEARCH_PLUGIN} />);
expect(wrapper.find('h3').text()).toEqual('App Search');
expect(wrapper.find('.enterpriseSearchProduct--appSearch')).toHaveLength(1);
});
});
describe('SideNavLink', () => {
it('renders', () => {
const wrapper = shallow(<SideNavLink to="/">Link</SideNavLink>);
expect(wrapper.type()).toEqual('li');
expect(wrapper.find(EuiLink)).toHaveLength(1);
expect(wrapper.find('.enterpriseSearchNavLinks__item')).toHaveLength(1);
});
it('renders an external link if isExternal is true', () => {
const wrapper = shallow(
<SideNavLink to="http://website.com" isExternal>
Link
</SideNavLink>
);
const externalLink = wrapper.find(EuiLinkExternal);
expect(externalLink).toHaveLength(1);
expect(externalLink.prop('href')).toEqual('http://website.com');
expect(externalLink.prop('target')).toEqual('_blank');
});
it('sets an active class if the current path matches the nav link', () => {
(useLocation as jest.Mock).mockImplementationOnce(() => ({ pathname: '/test/' }));
const wrapper = shallow(<SideNavLink to="/test">Link</SideNavLink>);
expect(wrapper.find('.enterpriseSearchNavLinks__item--isActive')).toHaveLength(1);
});
it('sets an active class if the current path is / and the link isRoot', () => {
(useLocation as jest.Mock).mockImplementationOnce(() => ({ pathname: '/' }));
const wrapper = shallow(
<SideNavLink to="/test" isRoot>
Link
</SideNavLink>
);
expect(wrapper.find('.enterpriseSearchNavLinks__item--isActive')).toHaveLength(1);
});
it('passes down custom classes and props', () => {
const wrapper = shallow(
<SideNavLink to="/" className="testing" data-test-subj="testing">
Link
</SideNavLink>
);
expect(wrapper.find('.testing')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="testing"]')).toHaveLength(1);
});
});

View file

@ -0,0 +1,107 @@
/*
* 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, { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { i18n } from '@kbn/i18n';
import { EuiIcon, EuiTitle, EuiText, EuiLink as EuiLinkExternal } from '@elastic/eui'; // TODO: Remove EuiLinkExternal after full Kibana transition
import { EuiLink } from '../react_router_helpers';
import { ENTERPRISE_SEARCH_PLUGIN } from '../../../../common/constants';
import { NavContext, INavContext } from './layout';
import './side_nav.scss';
/**
* Side navigation - product & icon + links wrapper
*/
interface ISideNavProps {
// Expects product plugin constants (@see common/constants.ts)
product: {
NAME: string;
ID: string;
};
}
export const SideNav: React.FC<ISideNavProps> = ({ product, children }) => {
return (
<nav
id="enterpriseSearchNav"
aria-label={i18n.translate('xpack.enterpriseSearch.nav.hierarchy', {
defaultMessage: 'Secondary', // The main Kibana nav is primary
})}
>
<div className={`enterpriseSearchProduct enterpriseSearchProduct--${product.ID}`}>
<div className="enterpriseSearchProduct__icon">
<EuiIcon type="logoEnterpriseSearch" />
</div>
<div className="enterpriseSearchProduct__title">
<EuiText size="xs" color="subdued">
{ENTERPRISE_SEARCH_PLUGIN.NAME}
</EuiText>
<EuiTitle size="xs">
<h3>{product.NAME}</h3>
</EuiTitle>
</div>
</div>
<ul className="enterpriseSearchNavLinks">{children}</ul>
</nav>
);
};
/**
* Side navigation link item
*/
interface ISideNavLinkProps {
to: string;
isExternal?: boolean;
className?: string;
isRoot?: boolean;
}
export const SideNavLink: React.FC<ISideNavLinkProps> = ({
isExternal,
to,
children,
className,
isRoot,
...rest
}) => {
const { closeNavigation } = useContext(NavContext) as INavContext;
const { pathname } = useLocation();
const currentPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const isActive = currentPath === to || (isRoot && currentPath === '');
const classes = classNames('enterpriseSearchNavLinks__item', className, {
'enterpriseSearchNavLinks__item--isActive': !isExternal && isActive, // eslint-disable-line @typescript-eslint/naming-convention
});
return (
<li>
{isExternal ? (
<EuiLinkExternal
{...rest}
className={classes}
href={to}
target="_blank"
onClick={closeNavigation}
>
{children}
</EuiLinkExternal>
) : (
<EuiLink {...rest} className={classes} to={to} onClick={closeNavigation}>
{children}
</EuiLink>
)}
</li>
);
};

View file

@ -0,0 +1,25 @@
<svg width="240" height="185" viewBox="0 0 240 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="185">
<rect width="240" height="185" fill="#F7F8F9"/>
</mask>
<g mask="url(#mask0)">
<circle opacity="0.06" cx="-8" cy="3" r="149" fill="url(#paint0_linear)"/>
<path d="M57 3C57 38.3462 27.8985 67 -8 67C-43.8985 67 -73 38.3462 -73 3C-73 -32.3462 -43.8985 -61 -8 -61C27.8985 -61 57 -32.3462 57 3Z" fill="url(#paint1_linear)"/>
<circle opacity="0.33" cx="-8" cy="3" r="32" fill="black"/>
<circle cx="-8" cy="3" r="24" fill="url(#paint2_linear)"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="45.5" y1="-59.5" x2="104.5" y2="75" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="22" y1="-6" x2="-8" y2="67" gradientUnits="userSpaceOnUse">
<stop stop-color="#009187"/>
<stop offset="1" stop-color="#01BEB2"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="-8" y1="5.5" x2="-8" y2="27" gradientUnits="userSpaceOnUse">
<stop stop-color="#D5A100"/>
<stop offset="1" stop-color="#FEC514"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -73,5 +73,14 @@ describe('EUI & React Router Component Helpers', () => {
expect(mockHistory.push).not.toHaveBeenCalled();
});
it('calls inherited onClick actions in addition to default navigation', () => {
const customOnClick = jest.fn(); // Can be anything from telemetry to a state reset
const wrapper = mount(<EuiReactRouterLink to="/narnia" onClick={customOnClick} />);
wrapper.find(EuiLink).simulate('click', { shiftKey: true });
expect(customOnClick).toHaveBeenCalled();
});
});
});

View file

@ -19,13 +19,15 @@ import { letBrowserHandleEvent } from './link_events';
interface IEuiReactRouterProps {
to: string;
onClick?(): void;
}
export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({ to, children }) => {
export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({ to, onClick, children }) => {
const history = useHistory();
const onClick = (event: React.MouseEvent) => {
if (letBrowserHandleEvent(event)) return;
const reactRouterLinkClick = (event: React.MouseEvent) => {
if (onClick) onClick(); // Run any passed click events (e.g. telemetry)
if (letBrowserHandleEvent(event)) return; // Return early if the link behavior shouldn't be handled by React Router
// Prevent regular link behavior, which causes a browser refresh.
event.preventDefault();
@ -37,21 +39,29 @@ export const EuiReactRouterHelper: React.FC<IEuiReactRouterProps> = ({ to, child
// Generate the correct link href (with basename etc. accounted for)
const href = history.createHref({ pathname: to });
const reactRouterProps = { href, onClick };
const reactRouterProps = { href, onClick: reactRouterLinkClick };
return React.cloneElement(children as React.ReactElement, reactRouterProps);
};
type TEuiReactRouterLinkProps = EuiLinkAnchorProps & IEuiReactRouterProps;
type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps;
export const EuiReactRouterLink: React.FC<TEuiReactRouterLinkProps> = ({ to, ...rest }) => (
<EuiReactRouterHelper to={to}>
export const EuiReactRouterLink: React.FC<TEuiReactRouterLinkProps> = ({
to,
onClick,
...rest
}) => (
<EuiReactRouterHelper to={to} onClick={onClick}>
<EuiLink {...rest} />
</EuiReactRouterHelper>
);
export const EuiReactRouterButton: React.FC<TEuiReactRouterButtonProps> = ({ to, ...rest }) => (
<EuiReactRouterHelper to={to}>
export const EuiReactRouterButton: React.FC<TEuiReactRouterButtonProps> = ({
to,
onClick,
...rest
}) => (
<EuiReactRouterHelper to={to} onClick={onClick}>
<EuiButton {...rest} />
</EuiReactRouterHelper>
);