diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index a7b29a1e6b457f..f8b4261114a22d 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -7,7 +7,7 @@ "optionalPlugins": ["usageCollection", "security", "home", "spaces", "cloud"], "server": true, "ui": true, - "requiredBundles": ["home"], + "requiredBundles": ["home", "kibanaReact"], "owner": { "name": "Enterprise Search", "githubTeam": "enterprise-search-frontend" diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/index.ts new file mode 100644 index 00000000000000..a7699848831b25 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AppSearchPageTemplate } from './page_template'; +export { useAppSearchNav } from './nav'; +export { KibanaHeaderActions } from './kibana_header_actions'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx new file mode 100644 index 00000000000000..8b06f4b26835d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues } from '../../../__mocks__/kea_logic'; + +jest.mock('../../../shared/layout', () => ({ + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); + +import { useAppSearchNav } from './nav'; + +describe('useAppSearchNav', () => { + it('always generates a default engines nav item', () => { + setMockValues({ myRole: {} }); + + expect(useAppSearchNav()).toEqual([ + { + id: '', + name: '', + items: [ + { + id: 'engines', + name: 'Engines', + href: '/engines', + items: [], + }, + ], + }, + ]); + }); + + it('generates a settings nav item if the user can view settings', () => { + setMockValues({ myRole: { canViewSettings: true } }); + + expect(useAppSearchNav()).toEqual([ + { + id: '', + name: '', + items: [ + { + id: 'engines', + name: 'Engines', + href: '/engines', + items: [], + }, + { + id: 'settings', + name: 'Settings', + href: '/settings', + }, + ], + }, + ]); + }); + + it('generates a credentials nav item if the user can view credentials', () => { + setMockValues({ myRole: { canViewAccountCredentials: true } }); + + expect(useAppSearchNav()).toEqual([ + { + id: '', + name: '', + items: [ + { + id: 'engines', + name: 'Engines', + href: '/engines', + items: [], + }, + { + id: 'credentials', + name: 'Credentials', + href: '/credentials', + }, + ], + }, + ]); + }); + + it('generates a users & roles nav item if the user can view role mappings', () => { + setMockValues({ myRole: { canViewRoleMappings: true } }); + + expect(useAppSearchNav()).toEqual([ + { + id: '', + name: '', + items: [ + { + id: 'engines', + name: 'Engines', + href: '/engines', + items: [], + }, + { + id: 'usersRoles', + name: 'Users & roles', + href: '/role_mappings', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx new file mode 100644 index 00000000000000..57fa740caebec2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useValues } from 'kea'; + +import { EuiSideNavItemType } from '@elastic/eui'; + +import { generateNavLink } from '../../../shared/layout'; +import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; + +import { AppLogic } from '../../app_logic'; +import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; +import { CREDENTIALS_TITLE } from '../credentials'; +import { ENGINES_TITLE } from '../engines'; +import { SETTINGS_TITLE } from '../settings'; + +export const useAppSearchNav = () => { + const { + myRole: { canViewSettings, canViewAccountCredentials, canViewRoleMappings }, + } = useValues(AppLogic); + + const navItems: Array> = [ + { + id: 'engines', + name: ENGINES_TITLE, + ...generateNavLink({ to: ENGINES_PATH, isRoot: true }), + items: [], // TODO: Engine nav + }, + ]; + + if (canViewSettings) { + navItems.push({ + id: 'settings', + name: SETTINGS_TITLE, + ...generateNavLink({ to: SETTINGS_PATH }), + }); + } + + if (canViewAccountCredentials) { + navItems.push({ + id: 'credentials', + name: CREDENTIALS_TITLE, + ...generateNavLink({ to: CREDENTIALS_PATH }), + }); + } + + if (canViewRoleMappings) { + navItems.push({ + id: 'usersRoles', + name: ROLE_MAPPINGS_TITLE, + ...generateNavLink({ to: ROLE_MAPPINGS_PATH }), + }); + } + + // Root level items are meant to be section headers, but the AS nav (currently) + // isn't organized this way. So we create a fake empty parent item here + // to cause all our navItems to properly render as nav links. + return [{ id: '', name: '', items: navItems }]; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx new file mode 100644 index 00000000000000..8f47d5f1c46444 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./nav', () => ({ + useAppSearchNav: () => [], +})); + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SetAppSearchChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchPageTemplate } from '../../../shared/layout'; +import { SendAppSearchTelemetry } from '../../../shared/telemetry'; + +import { AppSearchPageTemplate } from './page_template'; + +describe('AppSearchPageTemplate', () => { + it('renders', () => { + const wrapper = shallow( + +
world
+
+ ); + + expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplate); + expect(wrapper.prop('solutionNav')).toEqual({ name: 'App Search', items: [] }); + expect(wrapper.find('.hello').text()).toEqual('world'); + }); + + describe('page chrome', () => { + it('takes a breadcrumb array & renders a product-specific page chrome', () => { + const wrapper = shallow(); + const setPageChrome = wrapper.find(EnterpriseSearchPageTemplate).prop('setPageChrome') as any; + + expect(setPageChrome.type).toEqual(SetAppSearchChrome); + expect(setPageChrome.props.trail).toEqual(['Some page']); + }); + }); + + describe('page telemetry', () => { + it('takes a metric & renders product-specific telemetry viewed event', () => { + const wrapper = shallow(); + + expect(wrapper.find(SendAppSearchTelemetry).prop('action')).toEqual('viewed'); + expect(wrapper.find(SendAppSearchTelemetry).prop('metric')).toEqual('some_page'); + }); + }); + + it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplate accepts', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('pageHeader')!.pageTitle).toEqual( + 'hello world' + ); + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('isLoading')).toEqual(false); + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('emptyState')).toEqual(
); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx new file mode 100644 index 00000000000000..31f2eb3215e05a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/page_template.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { SetAppSearchChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchPageTemplate, PageTemplateProps } from '../../../shared/layout'; +import { SendAppSearchTelemetry } from '../../../shared/telemetry'; + +import { useAppSearchNav } from './nav'; + +export const AppSearchPageTemplate: React.FC = ({ + children, + pageChrome, + pageViewTelemetry, + ...pageTemplateProps +}) => { + return ( + } + > + {pageViewTelemetry && } + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx index d7ce8053c71f02..308022ccb2e5a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx @@ -12,7 +12,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; @@ -44,13 +43,6 @@ describe('RoleMappings', () => { expect(wrapper.find(RoleMappingsTable)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders RoleMapping flyout', () => { setMockValues({ ...mockValues, roleMappingFlyoutOpen: true }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index 78d0a5cbc8638e..db0e6e6dead111 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -9,11 +9,10 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; +import { AppSearchPageTemplate } from '../layout'; import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING } from './constants'; import { RoleMapping } from './role_mapping'; @@ -38,11 +37,12 @@ export const RoleMappings: React.FC = () => { return resetState; }, []); - if (dataLoading) return ; - const roleMappingsSection = ( - <> - initializeRoleMapping()} /> +
+ initializeRoleMapping()} + /> { shouldShowAuthProvider={multipleAuthProvidersConfig} handleDeleteMapping={handleDeleteMapping} /> - +
); return ( - <> - + {roleMappingFlyoutOpen && } - {roleMappingsSection} - + ); }; 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 a491efcb234dca..caf0f805e8ca7e 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 @@ -25,7 +25,7 @@ import { EngineNav, EngineRouter } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview, ENGINES_TITLE } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; -import { KibanaHeaderActions } from './components/layout/kibana_header_actions'; +import { KibanaHeaderActions } from './components/layout'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; import { RoleMappings } from './components/role_mappings'; @@ -92,6 +92,11 @@ export const AppSearchConfigured: React.FC> = (props) = )} + {canViewRoleMappings && ( + + + + )} } readOnlyMode={readOnlyMode}> @@ -110,11 +115,6 @@ export const AppSearchConfigured: React.FC> = (props) = - {canViewRoleMappings && ( - - - - )} {canManageEngines && ( 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 index 2dd5254cee7f1e..856d483e174a69 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/index.ts @@ -5,5 +5,9 @@ * 2.0. */ +export { EnterpriseSearchPageTemplate, PageTemplateProps } from './page_template'; +export { generateNavLink } from './nav_link_helpers'; + +// TODO: Delete these once KibanaPageTemplate migration is done export { Layout } from './layout'; export { SideNav, SideNavLink, SideNavItem } from './side_nav'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts new file mode 100644 index 00000000000000..b51416ac76ca78 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues } from '../../__mocks__/kea_logic'; + +jest.mock('../react_router_helpers', () => ({ + generateReactRouterProps: ({ to }: { to: string }) => ({ + href: `/app/enterprise_search${to}`, + onClick: () => mockKibanaValues.navigateToUrl(to), + }), +})); + +import { generateNavLink, getNavLinkActive } from './nav_link_helpers'; + +describe('generateNavLink', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockKibanaValues.history.location.pathname = '/current_page'; + }); + + it('generates React Router props & isSelected (active) state for use within an EuiSideNavItem obj', () => { + const navItem = generateNavLink({ to: '/test' }); + + expect(navItem.href).toEqual('/app/enterprise_search/test'); + + navItem.onClick({} as any); + expect(mockKibanaValues.navigateToUrl).toHaveBeenCalledWith('/test'); + + expect(navItem.isSelected).toEqual(false); + }); + + describe('getNavLinkActive', () => { + it('returns true when the current path matches the link path', () => { + mockKibanaValues.history.location.pathname = '/test'; + const isSelected = getNavLinkActive({ to: '/test' }); + + expect(isSelected).toEqual(true); + }); + + describe('isRoot', () => { + it('returns true if the current path is "/"', () => { + mockKibanaValues.history.location.pathname = '/'; + const isSelected = getNavLinkActive({ to: '/overview', isRoot: true }); + + expect(isSelected).toEqual(true); + }); + }); + + describe('shouldShowActiveForSubroutes', () => { + it('returns true if the current path is a subroute of the passed path', () => { + mockKibanaValues.history.location.pathname = '/hello/world'; + const isSelected = getNavLinkActive({ to: '/hello', shouldShowActiveForSubroutes: true }); + + expect(isSelected).toEqual(true); + }); + + it('returns false if not', () => { + mockKibanaValues.history.location.pathname = '/hello/world'; + const isSelected = getNavLinkActive({ to: '/hello' }); + + expect(isSelected).toEqual(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts new file mode 100644 index 00000000000000..6124636af3f992 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav_link_helpers.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { stripTrailingSlash } from '../../../../common/strip_slashes'; + +import { KibanaLogic } from '../kibana'; +import { generateReactRouterProps, ReactRouterProps } from '../react_router_helpers'; + +interface Params { + to: string; + isRoot?: boolean; + shouldShowActiveForSubroutes?: boolean; +} + +export const generateNavLink = ({ to, ...rest }: Params & ReactRouterProps) => { + return { + ...generateReactRouterProps({ to, ...rest }), + isSelected: getNavLinkActive({ to, ...rest }), + }; +}; + +export const getNavLinkActive = ({ + to, + isRoot = false, + shouldShowActiveForSubroutes = false, +}: Params): boolean => { + const { pathname } = KibanaLogic.values.history.location; + const currentPath = stripTrailingSlash(pathname); + + const isActive = + currentPath === to || + (shouldShowActiveForSubroutes && currentPath.startsWith(to)) || + (isRoot && currentPath === ''); + + return isActive; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.scss new file mode 100644 index 00000000000000..9ddd68277c9bc9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.scss @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +.enterpriseSearchPageTemplate { + position: relative; + + &__content { + // Note: relative positioning is required for our centered Loading component + position: relative; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx new file mode 100644 index 00000000000000..5b02756e44b524 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.test.tsx @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues } from '../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { KibanaPageTemplate } from '../../../../../../../src/plugins/kibana_react/public'; +import { FlashMessages } from '../flash_messages'; +import { Loading } from '../loading'; + +import { EnterpriseSearchPageTemplate } from './page_template'; + +describe('EnterpriseSearchPageTemplate', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockValues({ readOnlyMode: false }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(KibanaPageTemplate); + }); + + it('renders children', () => { + const wrapper = shallow( + +
world
+
+ ); + + expect(wrapper.find('.hello').text()).toEqual('world'); + }); + + describe('loading state', () => { + it('renders a loading icon in place of children', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(Loading).exists()).toBe(true); + expect(wrapper.find('.test').exists()).toBe(false); + }); + + it('renders children & does not render a loading icon when the page is done loading', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(Loading).exists()).toBe(false); + expect(wrapper.find('.test').exists()).toBe(true); + }); + }); + + describe('empty state', () => { + it('renders a custom empty state in place of children', () => { + const wrapper = shallow( + Nothing here yet!
} + > +
+ + ); + + expect(wrapper.find('.emptyState').exists()).toBe(true); + expect(wrapper.find('.test').exists()).toBe(false); + + // @see https://github.com/elastic/kibana/blob/master/dev_docs/tutorials/kibana_page_template.mdx#isemptystate + // if you want to use KibanaPageTemplate's `isEmptyState` without a custom emptyState + }); + + it('does not render the custom empty state if the page is not empty', () => { + const wrapper = shallow( + Nothing here yet!
} + > +
+ + ); + + expect(wrapper.find('.emptyState').exists()).toBe(false); + expect(wrapper.find('.test').exists()).toBe(true); + }); + + it('does not render an empty state if the page is still loading', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(Loading).exists()).toBe(true); + expect(wrapper.find('.emptyState').exists()).toBe(false); + }); + }); + + describe('read-only mode', () => { + it('renders a callout if in read-only mode', () => { + setMockValues({ readOnlyMode: true }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut).exists()).toBe(true); + }); + + it('does not render a callout if not in read-only mode', () => { + setMockValues({ readOnlyMode: false }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + }); + }); + + describe('flash messages', () => { + it('renders FlashMessages by default', () => { + const wrapper = shallow(); + + expect(wrapper.find(FlashMessages).exists()).toBe(true); + }); + + it('does not render FlashMessages if hidden', () => { + // Example use case: manually showing flash messages in an open flyout or modal + // and not wanting to duplicate flash messages on the overlayed page + const wrapper = shallow(); + + expect(wrapper.find(FlashMessages).exists()).toBe(false); + }); + }); + + describe('page chrome', () => { + const SetPageChrome = () =>
; + + it('renders a product-specific ', () => { + const wrapper = shallow(} />); + + expect(wrapper.find(SetPageChrome).exists()).toBe(true); + }); + + it('invokes page chrome immediately (without waiting for isLoading to be finished)', () => { + const wrapper = shallow( + } isLoading /> + ); + + expect(wrapper.find(SetPageChrome).exists()).toBe(true); + + // This behavior is in contrast to page view telemetry, which is invoked after isLoading finishes + // In addition to the pageHeader prop also changing immediately, this makes navigation feel much snappier + }); + }); + + describe('EuiPageTemplate props', () => { + it('overrides the restrictWidth prop', () => { + const wrapper = shallow(); + + expect(wrapper.find(KibanaPageTemplate).prop('restrictWidth')).toEqual(true); + }); + + it('passes down any ...pageTemplateProps that EuiPageTemplate accepts', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(KibanaPageTemplate).prop('template')).toEqual('empty'); + expect(wrapper.find(KibanaPageTemplate).prop('paddingSize')).toEqual('s'); + expect(wrapper.find(KibanaPageTemplate).prop('pageHeader')!.pageTitle).toEqual('hello world'); + }); + + it('sets enterpriseSearchPageTemplate classNames while still accepting custom classNames', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(KibanaPageTemplate).prop('className')).toEqual( + 'enterpriseSearchPageTemplate hello' + ); + expect(wrapper.find(KibanaPageTemplate).prop('pageContentProps')!.className).toEqual( + 'enterpriseSearchPageTemplate__content world' + ); + }); + + it('automatically sets the Enterprise Search logo onto passed solution navs', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(KibanaPageTemplate).prop('solutionNav')).toEqual({ + icon: 'logoEnterpriseSearch', + name: 'Enterprise Search', + items: [], + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx new file mode 100644 index 00000000000000..affec119215455 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { + KibanaPageTemplate, + KibanaPageTemplateProps, +} from '../../../../../../../src/plugins/kibana_react/public'; + +import { FlashMessages } from '../flash_messages'; +import { HttpLogic } from '../http'; +import { BreadcrumbTrail } from '../kibana_chrome/generate_breadcrumbs'; +import { Loading } from '../loading'; + +import './page_template.scss'; + +/* + * EnterpriseSearchPageTemplate is a light wrapper for KibanaPageTemplate (which + * is a light wrapper for EuiPageTemplate). It should contain only concerns shared + * between both AS & WS, which should have their own AppSearchPageTemplate & + * WorkplaceSearchPageTemplate sitting on top of this template (:nesting_dolls:), + * which in turn manages individual product-specific concerns (e.g. side navs, telemetry, etc.) + * + * @see https://github.com/elastic/kibana/tree/master/src/plugins/kibana_react/public/page_template + * @see https://elastic.github.io/eui/#/layout/page + */ + +export type PageTemplateProps = KibanaPageTemplateProps & { + hideFlashMessages?: boolean; + isLoading?: boolean; + emptyState?: React.ReactNode; + setPageChrome?: React.ReactNode; + // Used by product-specific page templates + pageChrome?: BreadcrumbTrail; + pageViewTelemetry?: string; +}; + +export const EnterpriseSearchPageTemplate: React.FC = ({ + children, + className, + hideFlashMessages, + isLoading, + isEmptyState, + emptyState, + setPageChrome, + solutionNav, + ...pageTemplateProps +}) => { + const { readOnlyMode } = useValues(HttpLogic); + const hasCustomEmptyState = !!emptyState; + const showCustomEmptyState = hasCustomEmptyState && isEmptyState; + + return ( + + {setPageChrome} + {readOnlyMode && ( + <> + + + + )} + {!hideFlashMessages && } + {isLoading ? : showCustomEmptyState ? emptyState : children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx index 7fded20cdd87e6..a04e628e0c4f9c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.test.tsx @@ -5,22 +5,22 @@ * 2.0. */ -import { mockKibanaValues } from '../../__mocks__/kea_logic'; -import { mockHistory } from '../../__mocks__/react_router'; +jest.mock('./', () => ({ + generateReactRouterProps: ({ to }: { to: string }) => ({ + href: `/app/enterprise_search${to}`, + onClick: () => {}, + }), +})); import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { shallow } from 'enzyme'; import { EuiLink, EuiButton, EuiButtonEmpty, EuiPanel, EuiCard } from '@elastic/eui'; import { EuiLinkTo, EuiButtonTo, EuiButtonEmptyTo, EuiPanelTo, EuiCardTo } from './eui_components'; -describe('EUI & React Router Component Helpers', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - +describe('React Router EUI component helpers', () => { it('renders an EuiLink', () => { const wrapper = shallow(); @@ -54,64 +54,18 @@ describe('EUI & React Router Component Helpers', () => { }); it('passes down all ...rest props', () => { - const wrapper = shallow(); + const wrapper = shallow(); const link = wrapper.find(EuiLink); expect(link.prop('external')).toEqual(true); - expect(link.prop('data-test-subj')).toEqual('foo'); + expect(link.prop('data-test-subj')).toEqual('test'); }); - it('renders with the correct href and onClick props', () => { - const wrapper = mount(); + it('renders with generated href and onClick props', () => { + const wrapper = shallow(); const link = wrapper.find(EuiLink); expect(link.prop('onClick')).toBeInstanceOf(Function); - expect(link.prop('href')).toEqual('/app/enterprise_search/foo/bar'); - expect(mockHistory.createHref).toHaveBeenCalled(); - }); - - it('renders with the correct non-basenamed href when shouldNotCreateHref is passed', () => { - const wrapper = mount(); - const link = wrapper.find(EuiLink); - - expect(link.prop('href')).toEqual('/foo/bar'); - expect(mockHistory.createHref).not.toHaveBeenCalled(); - }); - - describe('onClick', () => { - it('prevents default navigation and uses React Router history', () => { - const wrapper = mount(); - - const simulatedEvent = { - button: 0, - target: { getAttribute: () => '_self' }, - preventDefault: jest.fn(), - }; - wrapper.find(EuiLink).simulate('click', simulatedEvent); - - expect(simulatedEvent.preventDefault).toHaveBeenCalled(); - expect(mockKibanaValues.navigateToUrl).toHaveBeenCalled(); - }); - - it('does not prevent default browser behavior on new tab/window clicks', () => { - const wrapper = mount(); - - const simulatedEvent = { - shiftKey: true, - target: { getAttribute: () => '_blank' }, - }; - wrapper.find(EuiLink).simulate('click', simulatedEvent); - - expect(mockKibanaValues.navigateToUrl).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(); - }); + expect(link.prop('href')).toEqual('/app/enterprise_search/hello/world'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx index b9fee9d16273b8..e7eb36f279fc75 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_components.tsx @@ -7,8 +7,6 @@ import React from 'react'; -import { useValues } from 'kea'; - import { EuiLink, EuiButton, @@ -22,55 +20,10 @@ import { } from '@elastic/eui'; import { EuiPanelProps } from '@elastic/eui/src/components/panel/panel'; -import { HttpLogic } from '../http'; -import { KibanaLogic } from '../kibana'; - -import { letBrowserHandleEvent, createHref } from './'; +import { generateReactRouterProps, ReactRouterProps } from './'; /** - * Generates EUI components with React-Router-ified links - * - * Based off of EUI's recommendations for handling React Router: - * https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51 - */ - -interface ReactRouterProps { - to: string; - onClick?(): void; - // Used to navigate outside of the React Router plugin basename but still within Kibana, - // e.g. if we need to go from Enterprise Search to App Search - shouldNotCreateHref?: boolean; -} - -export const ReactRouterHelper: React.FC = ({ - to, - onClick, - shouldNotCreateHref, - children, -}) => { - const { navigateToUrl, history } = useValues(KibanaLogic); - const { http } = useValues(HttpLogic); - - // Generate the correct link href (with basename etc. accounted for) - const href = createHref(to, { history, http }, { shouldNotCreateHref }); - - 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(); - - // Perform SPA navigation. - navigateToUrl(to, { shouldNotCreateHref }); - }; - - const reactRouterProps = { href, onClick: reactRouterLinkClick }; - return React.cloneElement(children as React.ReactElement, reactRouterProps); -}; - -/** - * Component helpers + * Correctly typed component helpers with React-Router-friendly `href` and `onClick` props */ type ReactRouterEuiLinkProps = ReactRouterProps & EuiLinkAnchorProps; @@ -79,11 +32,7 @@ export const EuiLinkTo: React.FC = ({ onClick, shouldNotCreateHref, ...rest -}) => ( - - - -); +}) => ; type ReactRouterEuiButtonProps = ReactRouterProps & EuiButtonProps; export const EuiButtonTo: React.FC = ({ @@ -91,11 +40,7 @@ export const EuiButtonTo: React.FC = ({ onClick, shouldNotCreateHref, ...rest -}) => ( - - - -); +}) => ; type ReactRouterEuiButtonEmptyProps = ReactRouterProps & EuiButtonEmptyProps; export const EuiButtonEmptyTo: React.FC = ({ @@ -104,9 +49,7 @@ export const EuiButtonEmptyTo: React.FC = ({ shouldNotCreateHref, ...rest }) => ( - - - + ); type ReactRouterEuiPanelProps = ReactRouterProps & EuiPanelProps; @@ -115,11 +58,7 @@ export const EuiPanelTo: React.FC = ({ onClick, shouldNotCreateHref, ...rest -}) => ( - - - -); +}) => ; type ReactRouterEuiCardProps = ReactRouterProps & EuiCardProps; export const EuiCardTo: React.FC = ({ @@ -127,8 +66,4 @@ export const EuiCardTo: React.FC = ({ onClick, shouldNotCreateHref, ...rest -}) => ( - - - -); +}) => ; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts new file mode 100644 index 00000000000000..dc8bf28a444071 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues } from '../../__mocks__/kea_logic'; +import { mockHistory } from '../../__mocks__/react_router'; + +import { generateReactRouterProps } from './'; + +describe('generateReactRouterProps', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('generates React-Router-friendly href and onClick props', () => { + expect(generateReactRouterProps({ to: '/hello/world' })).toEqual({ + href: '/app/enterprise_search/hello/world', + onClick: expect.any(Function), + }); + expect(mockHistory.createHref).toHaveBeenCalled(); + }); + + it('renders with the correct non-basenamed href when shouldNotCreateHref is passed', () => { + expect(generateReactRouterProps({ to: '/hello/world', shouldNotCreateHref: true })).toEqual({ + href: '/hello/world', + onClick: expect.any(Function), + }); + }); + + describe('onClick', () => { + it('prevents default navigation and uses React Router history', () => { + const mockEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + } as any; + + const { onClick } = generateReactRouterProps({ to: '/test' }); + onClick(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockKibanaValues.navigateToUrl).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const mockEvent = { + shiftKey: true, + target: { getAttribute: () => '_blank' }, + } as any; + + const { onClick } = generateReactRouterProps({ to: '/test' }); + onClick(mockEvent); + + expect(mockKibanaValues.navigateToUrl).not.toHaveBeenCalled(); + }); + + it('calls inherited onClick actions in addition to default navigation', () => { + const mockEvent = { preventDefault: jest.fn() } as any; + const customOnClick = jest.fn(); // Can be anything from telemetry to a state reset + + const { onClick } = generateReactRouterProps({ to: '/test', onClick: customOnClick }); + onClick(mockEvent); + + expect(customOnClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts new file mode 100644 index 00000000000000..d80eca19207bd5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/generate_react_router_props.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { HttpLogic } from '../http'; +import { KibanaLogic } from '../kibana'; + +import { letBrowserHandleEvent, createHref } from './'; + +/** + * Generates the `href` and `onClick` props for React-Router-friendly links + * + * Based off of EUI's recommendations for handling React Router: + * https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51 + * + * but separated out from EuiLink portion as we use this for multiple EUI components + */ + +export interface ReactRouterProps { + to: string; + onClick?(): void; + // Used to navigate outside of the React Router plugin basename but still within Kibana, + // e.g. if we need to go from Enterprise Search to App Search + shouldNotCreateHref?: boolean; +} + +export const generateReactRouterProps = ({ + to, + onClick, + shouldNotCreateHref, +}: ReactRouterProps) => { + const { navigateToUrl, history } = KibanaLogic.values; + const { http } = HttpLogic.values; + + // Generate the correct link href (with basename etc. accounted for) + const href = createHref(to, { history, http }, { shouldNotCreateHref }); + + 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(); + + // Perform SPA navigation. + navigateToUrl(to, { shouldNotCreateHref }); + }; + + return { href, onClick: reactRouterLinkClick }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts index 1a73c9c281b21f..17827b02302377 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts @@ -7,4 +7,5 @@ export { letBrowserHandleEvent } from './link_events'; export { createHref, CreateHrefOptions } from './create_href'; +export { generateReactRouterProps, ReactRouterProps } from './generate_react_router_props'; export { EuiLinkTo, EuiButtonTo, EuiButtonEmptyTo, EuiPanelTo, EuiCardTo } from './eui_components'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 47d481630510e2..9f40844e52470a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -193,7 +193,7 @@ export const ROLE_MAPPINGS_HEADING_DESCRIPTION = (productName: ProductName) => export const ROLE_MAPPINGS_HEADING_DOCS_LINK = i18n.translate( 'xpack.enterpriseSearch.roleMapping.roleMappingsHeadingDocsLink', - { defaultMessage: 'Learn more about role mappings' } + { defaultMessage: 'Learn more about role mappings.' } ); export const ROLE_MAPPINGS_HEADING_BUTTON = i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx index b2143c6ff44028..eee8b180d32819 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_heading.tsx @@ -35,7 +35,7 @@ interface Props { const ROLE_MAPPINGS_DOCS_HREF = '#TODO'; export const RoleMappingsHeading: React.FC = ({ productName, onClick }) => ( - <> +
@@ -58,5 +58,5 @@ export const RoleMappingsHeading: React.FC = ({ productName, onClick }) = - +
); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index f450ca556ebe25..67208c63ddf4cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants'; + import { ADD, UPDATE } from './constants/operations'; export type TOperation = typeof ADD | typeof UPDATE; @@ -36,4 +38,5 @@ export interface RoleMapping { }; } -export type ProductName = 'App Search' | 'Workplace Search'; +const productNames = [APP_SEARCH_PLUGIN.NAME, WORKPLACE_SEARCH_PLUGIN.NAME] as const; +export type ProductName = typeof productNames[number]; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx b/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx new file mode 100644 index 00000000000000..6e89274dca5703 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/get_page_header.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiPageHeaderProps } from '@elastic/eui'; + +/* + * Given an AppSearchPageTemplate or WorkplaceSearchPageTemplate, these + * helpers dive into various parts of the EuiPageHeader to make assertions + * slightly less of a pain in shallow renders + */ + +export const getPageHeader = (wrapper: ShallowWrapper) => { + const pageHeader = wrapper.prop('pageHeader') as EuiPageHeaderProps; + return pageHeader || {}; +}; + +export const getPageTitle = (wrapper: ShallowWrapper) => { + return getPageHeader(wrapper).pageTitle; +}; + +export const getPageDescription = (wrapper: ShallowWrapper) => { + return getPageHeader(wrapper).description; +}; + +export const getPageHeaderActions = (wrapper: ShallowWrapper) => { + const actions = getPageHeader(wrapper).rightSideItems || []; + + return shallow( +
+ {actions.map((action: React.ReactNode, i) => ( + {action} + ))} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts index e34ff763637b5a..ed5c3f85a888ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/index.ts @@ -10,6 +10,12 @@ export { mountAsync } from './mount_async'; export { mountWithIntl } from './mount_with_i18n'; export { shallowWithIntl } from './shallow_with_i18n'; export { rerender } from './enzyme_rerender'; +export { + getPageHeader, + getPageTitle, + getPageDescription, + getPageHeaderActions, +} from './get_page_header'; // Misc export { expectedAsyncError } from './expected_async_error'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts index e1c2a3b76e3ff1..8cdc1336817629 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts @@ -5,7 +5,8 @@ * 2.0. */ -export { WorkplaceSearchNav } from './nav'; +export { WorkplaceSearchPageTemplate } from './page_template'; +export { useWorkplaceSearchNav, WorkplaceSearchNav } from './nav'; export { WorkplaceSearchHeaderActions } from './kibana_header_actions'; export { AccountHeader } from './account_header'; export { PersonalDashboardLayout } from './personal_dashboard_layout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 8f37f608f4e282..90da5b3163ecfc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -5,7 +5,10 @@ * 2.0. */ -import '../../../__mocks__/enterprise_search_url.mock'; +jest.mock('../../../shared/layout', () => ({ + ...jest.requireActual('../../../shared/layout'), + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); import React from 'react'; @@ -13,7 +16,55 @@ import { shallow } from 'enzyme'; import { SideNav, SideNavLink } from '../../../shared/layout'; -import { WorkplaceSearchNav } from './'; +import { useWorkplaceSearchNav, WorkplaceSearchNav } from './'; + +describe('useWorkplaceSearchNav', () => { + it('returns an array of top-level Workplace Search nav items', () => { + expect(useWorkplaceSearchNav()).toEqual([ + { + id: '', + name: '', + items: [ + { + id: 'root', + name: 'Overview', + href: '/', + }, + { + id: 'sources', + name: 'Sources', + href: '/sources', + items: [], + }, + { + id: 'groups', + name: 'Groups', + href: '/groups', + items: [], + }, + { + id: 'usersRoles', + name: 'Users & roles', + href: '/role_mappings', + }, + { + id: 'security', + name: 'Security', + href: '/security', + }, + { + id: 'settings', + name: 'Settings', + href: '/settings', + items: [], + }, + ], + }, + ]); + }); +}); + +// TODO: Delete below once fully migrated to KibanaPageTemplate describe('WorkplaceSearchNav', () => { it('renders', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index fb3c8556029b25..8e7b13a6218214 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -7,10 +7,10 @@ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiSideNavItemType, EuiSpacer } from '@elastic/eui'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SideNav, SideNavLink } from '../../../shared/layout'; +import { generateNavLink, SideNav, SideNavLink } from '../../../shared/layout'; import { NAV } from '../../constants'; import { SOURCES_PATH, @@ -20,6 +20,51 @@ import { ORG_SETTINGS_PATH, } from '../../routes'; +export const useWorkplaceSearchNav = () => { + const navItems: Array> = [ + { + id: 'root', + name: NAV.OVERVIEW, + ...generateNavLink({ to: '/', isRoot: true }), + }, + { + id: 'sources', + name: NAV.SOURCES, + ...generateNavLink({ to: SOURCES_PATH }), + items: [], // TODO: Source subnav + }, + { + id: 'groups', + name: NAV.GROUPS, + ...generateNavLink({ to: GROUPS_PATH }), + items: [], // TODO: Group subnav + }, + { + id: 'usersRoles', + name: NAV.ROLE_MAPPINGS, + ...generateNavLink({ to: ROLE_MAPPINGS_PATH }), + }, + { + id: 'security', + name: NAV.SECURITY, + ...generateNavLink({ to: SECURITY_PATH }), + }, + { + id: 'settings', + name: NAV.SETTINGS, + ...generateNavLink({ to: ORG_SETTINGS_PATH }), + items: [], // TODO: Settings subnav + }, + ]; + + // Root level items are meant to be section headers, but the WS nav (currently) + // isn't organized this way. So we crate a fake empty parent item here + // to cause all our navItems to properly render as nav links. + return [{ id: '', name: '', items: navItems }]; +}; + +// TODO: Delete below once fully migrated to KibanaPageTemplate + interface Props { sourcesSubNav?: React.ReactNode; groupsSubNav?: React.ReactNode; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx new file mode 100644 index 00000000000000..622fddc449ca7d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./nav', () => ({ + useWorkplaceSearchNav: () => [], +})); + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SetWorkplaceSearchChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchPageTemplate } from '../../../shared/layout'; +import { SendWorkplaceSearchTelemetry } from '../../../shared/telemetry'; + +import { WorkplaceSearchPageTemplate } from './page_template'; + +describe('WorkplaceSearchPageTemplate', () => { + it('renders', () => { + const wrapper = shallow( + +
world
+
+ ); + + expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplate); + expect(wrapper.prop('solutionNav')).toEqual({ name: 'Workplace Search', items: [] }); + expect(wrapper.find('.hello').text()).toEqual('world'); + }); + + describe('page chrome', () => { + it('takes a breadcrumb array & renders a product-specific page chrome', () => { + const wrapper = shallow(); + const setPageChrome = wrapper.find(EnterpriseSearchPageTemplate).prop('setPageChrome') as any; + + expect(setPageChrome.type).toEqual(SetWorkplaceSearchChrome); + expect(setPageChrome.props.trail).toEqual(['Some page']); + }); + }); + + describe('page telemetry', () => { + it('takes a metric & renders product-specific telemetry viewed event', () => { + const wrapper = shallow(); + + expect(wrapper.find(SendWorkplaceSearchTelemetry).prop('action')).toEqual('viewed'); + expect(wrapper.find(SendWorkplaceSearchTelemetry).prop('metric')).toEqual('some_page'); + }); + }); + + describe('props', () => { + it('allows overriding the restrictWidth default', () => { + const wrapper = shallow(); + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('restrictWidth')).toEqual(true); + + wrapper.setProps({ restrictWidth: false }); + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('restrictWidth')).toEqual(false); + }); + + it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplate accepts', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('pageHeader')!.pageTitle).toEqual( + 'hello world' + ); + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('isLoading')).toEqual(false); + expect(wrapper.find(EnterpriseSearchPageTemplate).prop('emptyState')).toEqual(
); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx new file mode 100644 index 00000000000000..4a6e0d9c6e2ddc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/page_template.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { SetWorkplaceSearchChrome } from '../../../shared/kibana_chrome'; +import { EnterpriseSearchPageTemplate, PageTemplateProps } from '../../../shared/layout'; +import { SendWorkplaceSearchTelemetry } from '../../../shared/telemetry'; + +import { useWorkplaceSearchNav } from './nav'; + +export const WorkplaceSearchPageTemplate: React.FC = ({ + children, + pageChrome, + pageViewTelemetry, + ...pageTemplateProps +}) => { + return ( + } + > + {pageViewTelemetry && ( + + )} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 7e911b31c516b7..dd263c3bd69f5d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -141,9 +141,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - } restrictWidth readOnlyMode={readOnlyMode}> - - + } restrictWidth readOnlyMode={readOnlyMode}> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx index d7ce8053c71f02..308022ccb2e5a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx @@ -12,7 +12,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; @@ -44,13 +43,6 @@ describe('RoleMappings', () => { expect(wrapper.find(RoleMappingsTable)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders RoleMapping flyout', () => { setMockValues({ ...mockValues, roleMappingFlyoutOpen: true }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 46c426c3dad2a5..b153d012241939 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -9,11 +9,10 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; +import { WorkplaceSearchPageTemplate } from '../../components/layout'; import { ROLE_MAPPINGS_TABLE_HEADER } from './constants'; @@ -36,11 +35,12 @@ export const RoleMappings: React.FC = () => { initializeRoleMappings(); }, []); - if (dataLoading) return ; - const roleMappingsSection = ( - <> - initializeRoleMapping()} /> +
+ initializeRoleMapping()} + /> { initializeRoleMapping={initializeRoleMapping} handleDeleteMapping={handleDeleteMapping} /> - +
); return ( - <> - + {roleMappingFlyoutOpen && } - {roleMappingsSection} - + ); };