From a9771ab1cd4a9f089c7e1606709f931685711558 Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 12 Aug 2020 11:29:46 -0700 Subject: [PATCH] [Enterprise Search] Add solution-level side navigation (#74705) (#74882) * 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 --- .../public/applications/__mocks__/index.ts | 2 +- .../__mocks__/react_router_history.mock.ts | 10 +- .../components/empty_states/empty_state.tsx | 73 ++++++------ .../components/empty_states/error_state.tsx | 16 ++- .../components/empty_states/loading_state.tsx | 21 ++-- .../engine_overview/engine_overview.scss | 13 +- .../engine_overview/engine_overview.tsx | 111 +++++++++--------- .../applications/app_search/index.test.tsx | 54 ++++----- .../public/applications/app_search/index.tsx | 70 +++++++++-- .../applications/shared/layout/index.ts | 8 ++ .../applications/shared/layout/layout.scss | 81 +++++++++++++ .../shared/layout/layout.test.tsx | 61 ++++++++++ .../applications/shared/layout/layout.tsx | 60 ++++++++++ .../applications/shared/layout/side_nav.scss | 75 ++++++++++++ .../shared/layout/side_nav.test.tsx | 92 +++++++++++++++ .../applications/shared/layout/side_nav.tsx | 107 +++++++++++++++++ .../shared/layout/side_nav_bg.svg | 25 ++++ .../react_router_helpers/eui_link.test.tsx | 9 ++ .../shared/react_router_helpers/eui_link.tsx | 26 ++-- 19 files changed, 741 insertions(+), 173 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav_bg.svg diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts index 6f82946c0ea14..e999d40a3f8e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -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 { diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts index fd422465d87f1..779eb1a043e8c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -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), })); /** diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index d6c38629d8143..4d2b790e7fb97 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -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 ( - + <> - - - - - - - - } - titleSize="l" - body={ -

- -

- } - actions={ - - - - } - /> -
-
-
+ + + + + + } + titleSize="l" + body={ +

+ +

+ } + actions={ + + + + } + /> +
+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx index 3753ad5433e8f..c5a5f1fbb921f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -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 ( - + <> - - - - - - - + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx index 533dca7d0ab79..221091b79dc54 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -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 ( - + <> - - - - - - - - - - + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss index 2c7f7de6458e2..e39bbbc95564b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -/** - * Engine Overview - */ .engineOverview { - width: 100%; - - &__body { - padding: $euiSize; + padding: $euiSize; - @include euiBreakpoint('m', 'l', 'xl') { - padding: $euiSizeXL; - } + @include euiBreakpoint('m', 'l', 'xl') { + padding: $euiSizeXL; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 286c32b2a443b..acac5d17665b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -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 ; return ( - + <> - - - - - - -

- - -

-
-
- - - - - {metaEngines.length > 0 && ( - <> - - - -

- - -

-
-
- - - - - )} -
-
-
+ + + + +

+ + +

+
+
+ + + + + {metaEngines.length > 0 && ( + <> + + + +

+ + +

+
+
+ + + + + )} +
+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 45e318ca0f9d9..9e660d10053ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -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 { AppSearch } from './'; - -describe('App Search Routes', () => { - describe('/', () => { - it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); - const wrapper = shallow(); - - 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(); - - expect(wrapper.find(EngineOverview)).toHaveLength(1); - expect(wrapper.find(Redirect)).toHaveLength(0); - }); +import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { AppSearch, AppSearchNav } from './'; + +describe('AppSearch', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(Layout)).toHaveLength(1); + }); + + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(Layout)).toHaveLength(0); }); +}); - describe('/setup_guide', () => { - it('renders', () => { - const wrapper = shallow(); +describe('AppSearchNav', () => { + it('renders', () => { + const wrapper = shallow(); - expect(wrapper.find(SetupGuide)).toHaveLength(1); - }); + 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' + ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 8f7142f1631a9..d69b3ba29b0ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -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 ( + + + + + + + {/* Kibana displays a blank page on redirect if this isn't included */} + + + ); return ( - <> - - {!enterpriseSearchUrl ? : } - - + + - + + }> + + + {/* For some reason a Redirect to /engines just doesn't work here - it shows a blank page */} + + + + + + + + + + ); +}; + +export const AppSearchNav: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const externalUrl = `${enterpriseSearchUrl}/as#`; + + return ( + + + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.engines', { + defaultMessage: 'Engines', + })} + + + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.settings', { + defaultMessage: 'Account Settings', + })} + + + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.credentials', { + defaultMessage: 'Credentials', + })} + + + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.roleMappings', { + defaultMessage: 'Role Mappings', + })} + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts new file mode 100644 index 0000000000000..2211cdee6c730 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss new file mode 100644 index 0000000000000..c73a527147961 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss @@ -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; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx new file mode 100644 index 0000000000000..4053f2f4bb613 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx @@ -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(); + + expect(wrapper.find('.enterpriseSearchLayout')).toHaveLength(1); + }); + + it('renders navigation', () => { + const wrapper = shallow(Hello World} />); + + expect(wrapper.find('.enterpriseSearchLayout__sideBar')).toHaveLength(1); + expect(wrapper.find('.nav-test')).toHaveLength(1); + }); + + it('renders navigation toggle state', () => { + const wrapper = shallow(Hello World} />); + 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(} />); + + 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( + +
Test
+
+ ); + + expect(wrapper.find('.enterpriseSearchLayout__body')).toHaveLength(1); + expect(wrapper.find('.testing')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx new file mode 100644 index 0000000000000..b4497140b65b7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx @@ -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 = ({ 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 ( + + +
+ + {i18n.translate('xpack.enterpriseSearch.nav.menu', { + defaultMessage: 'Menu', + })} + +
+ {navigation} +
+ {children} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss new file mode 100644 index 0000000000000..d673542ba1983 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss @@ -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; + } + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx new file mode 100644 index 0000000000000..c117fa404a16b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx @@ -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( + +
Hello World
+
+ ); + + 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(); + + expect(wrapper.find('h3').text()).toEqual('App Search'); + expect(wrapper.find('.enterpriseSearchProduct--appSearch')).toHaveLength(1); + }); +}); + +describe('SideNavLink', () => { + it('renders', () => { + const wrapper = shallow(Link); + + 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( + + Link + + ); + 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(Link); + + 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( + + Link + + ); + + expect(wrapper.find('.enterpriseSearchNavLinks__item--isActive')).toHaveLength(1); + }); + + it('passes down custom classes and props', () => { + const wrapper = shallow( + + Link + + ); + + expect(wrapper.find('.testing')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="testing"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx new file mode 100644 index 0000000000000..5969fa7806a44 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx @@ -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 = ({ product, children }) => { + return ( + + ); +}; + +/** + * Side navigation link item + */ + +interface ISideNavLinkProps { + to: string; + isExternal?: boolean; + className?: string; + isRoot?: boolean; +} + +export const SideNavLink: React.FC = ({ + 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 ( +
  • + {isExternal ? ( + + {children} + + ) : ( + + {children} + + )} +
  • + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav_bg.svg b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav_bg.svg new file mode 100644 index 0000000000000..a19227ab7b7eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav_bg.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx index 7d4c068b21155..76ee8293f2c8b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -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(); + + wrapper.find(EuiLink).simulate('click', { shiftKey: true }); + + expect(customOnClick).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx index f486e432bae76..b53b2f2b3b650 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -19,13 +19,15 @@ import { letBrowserHandleEvent } from './link_events'; interface IEuiReactRouterProps { to: string; + onClick?(): void; } -export const EuiReactRouterHelper: React.FC = ({ to, children }) => { +export const EuiReactRouterHelper: React.FC = ({ 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 = ({ 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 = ({ to, ...rest }) => ( - +export const EuiReactRouterLink: React.FC = ({ + to, + onClick, + ...rest +}) => ( + ); -export const EuiReactRouterButton: React.FC = ({ to, ...rest }) => ( - +export const EuiReactRouterButton: React.FC = ({ + to, + onClick, + ...rest +}) => ( + );