[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:
parent
8f54198434
commit
6ee2460ebc
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
}));
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 |
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue