diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.test.tsx index cecf01c66d2412..9bd3ad87964412 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.test.tsx @@ -7,10 +7,11 @@ import { EuiButtonEmpty, - EuiContextMenuItem, EuiContextMenuPanel, EuiFieldSearch, EuiPopover, + EuiSelectable, + EuiSelectableListItem, } from '@elastic/eui'; import { act } from '@testing-library/react'; import React from 'react'; @@ -66,27 +67,89 @@ describe('SpacesPopoverList', () => { expect(wrapper.find(EuiContextMenuPanel)).toHaveLength(0); }); - it('clicking the button renders a context menu with the provided spaces', async () => { + it('clicking the button renders an EuiSelectable menu with the provided spaces', async () => { const wrapper = await setup(mockSpaces); await act(async () => { wrapper.find(EuiButtonEmpty).simulate('click'); }); wrapper.update(); - const menu = wrapper.find(EuiContextMenuPanel); - expect(menu).toHaveLength(1); + await act(async () => { + const menu = wrapper.find(EuiSelectable); + expect(menu).toHaveLength(1); - const items = menu.find(EuiContextMenuItem); - expect(items).toHaveLength(mockSpaces.length); + const items = menu.find(EuiSelectableListItem); + expect(items).toHaveLength(mockSpaces.length); - mockSpaces.forEach((space, index) => { - const spaceAvatar = items.at(index).find(SpaceAvatarInternal); - expect(spaceAvatar.props().space).toEqual(space); + mockSpaces.forEach((space, index) => { + const spaceAvatar = items.at(index).find(SpaceAvatarInternal); + expect(spaceAvatar.props().space).toEqual(space); + }); }); }); - it('Should NOT render a search box when there is less than 8 spaces', async () => { - const wrapper = await setup(mockSpaces); + it('should render a search box when there are 8 or more spaces', async () => { + const eightSpaces = mockSpaces.concat([ + { + id: 'space-3', + name: 'Space-3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, + { + id: 'space-6', + name: 'Space 6', + disabledFeatures: [], + }, + { + id: 'space-7', + name: 'Space 7', + disabledFeatures: [], + }, + ]); + const wrapper = await setup(eightSpaces); + await act(async () => { + wrapper.find(EuiButtonEmpty).simulate('click'); + }); + wrapper.update(); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + }); + + it('should NOT render a search box when there are less than 8 spaces', async () => { + const sevenSpaces = mockSpaces.concat([ + { + id: 'space-3', + name: 'Space-3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, + { + id: 'space-6', + name: 'Space 6', + disabledFeatures: [], + }, + ]); + + const wrapper = await setup(sevenSpaces); await act(async () => { wrapper.find(EuiButtonEmpty).simulate('click'); }); @@ -101,11 +164,11 @@ describe('SpacesPopoverList', () => { wrapper.find(EuiButtonEmpty).simulate('click'); }); wrapper.update(); - expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true); - wrapper.find(EuiPopover).props().closePopover(); - + await act(async () => { + wrapper.find(EuiPopover).props().closePopover(); + }); wrapper.update(); expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx index 9ddc698ef2c2b9..acc71203dfff1b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx @@ -7,15 +7,17 @@ import './spaces_popover_list.scss'; +import type { EuiSelectableOption } from '@elastic/eui'; import { EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFieldSearch, + EuiFocusTrap, + EuiLoadingSpinner, EuiPopover, + EuiPopoverTitle, + EuiSelectable, EuiText, } from '@elastic/eui'; -import React, { Component, memo } from 'react'; +import React, { Component, memo, Suspense } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -29,14 +31,12 @@ interface Props { } interface State { - searchTerm: string; allowSpacesListFocus: boolean; isPopoverOpen: boolean; } export class SpacesPopoverList extends Component { public state = { - searchTerm: '', allowSpacesListFocus: false, isPopoverOpen: false, }; @@ -56,152 +56,106 @@ export class SpacesPopoverList extends Component { closePopover={this.closePopover} panelPaddingSize="none" anchorPosition="downLeft" - ownFocus + ownFocus={false} > - {this.getMenuPanel()} + {this.getMenuPanel()} ); } private getMenuPanel = () => { - const { searchTerm } = this.state; - - const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); - - const panelProps = { - className: 'spcMenu', - title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { - defaultMessage: 'Spaces', - }), - }; - - if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { - return ( - - {this.renderSearchField()} - {this.renderSpacesListPanel(items, searchTerm)} - - ); - } + const options = this.getSpaceOptions(); + + const noSpacesMessage = ( + + + + ); - return ; + return ( + = SPACE_SEARCH_COUNT_THRESHOLD} + searchProps={ + this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD + ? ({ + placeholder: i18n.translate( + 'xpack.security.management.editRole.spacesPopoverList.findSpacePlaceholder', + { + defaultMessage: 'Find a space', + } + ), + compressed: true, + isClearable: true, + id: 'spacesPopoverListSearch', + } as any) + : undefined + } + noMatchesMessage={noSpacesMessage} + options={options} + singleSelection={true} + style={{ width: 300 }} + listProps={{ + rowHeight: 40, + showIcons: false, + onFocusBadge: false, + }} + > + {(list, search) => ( + <> + + {i18n.translate( + 'xpack.security.management.editRole.spacesPopoverList.selectSpacesTitle', + { + defaultMessage: 'Spaces', + } + )} + + {search} + {list} + + )} + + ); }; private onButtonClick = () => { this.setState({ isPopoverOpen: !this.state.isPopoverOpen, - searchTerm: '', }); }; private closePopover = () => { this.setState({ isPopoverOpen: false, - searchTerm: '', }); }; - private getVisibleSpaces = (searchTerm: string): Space[] => { - const { spaces } = this.props; - - let filteredSpaces = spaces; - if (searchTerm) { - filteredSpaces = spaces.filter((space) => { - const { name, description = '' } = space; - return ( - name.toLowerCase().indexOf(searchTerm) >= 0 || - description.toLowerCase().indexOf(searchTerm) >= 0 - ); - }); - } - - return filteredSpaces; - }; + private getSpaceOptions = (): EuiSelectableOption[] => { + const LazySpaceAvatar = memo(this.props.spacesApiUi.components.getSpaceAvatar); - private renderSpacesListPanel = (items: JSX.Element[], searchTerm: string) => { - if (items.length === 0) { - return ( - - - + return this.props.spaces.map((space) => { + const icon = ( + }> + + ); - } - - return ( - - ); - }; - - private renderSearchField = () => { - return ( -
- { - - } -
- ); - }; - - private onSearchKeyDown = (e: any) => { - // 9: tab - // 13: enter - // 40: arrow-down - const focusableKeyCodes = [9, 13, 40]; - - const keyCode = e.keyCode; - if (focusableKeyCodes.includes(keyCode)) { - // Allows the spaces list panel to recieve focus. This enables keyboard and screen reader navigation - this.setState({ - allowSpacesListFocus: true, - }); - } - }; - - private onSearchFocus = () => { - this.setState({ - allowSpacesListFocus: false, - }); - }; - - private onSearch = (searchTerm: string) => { - this.setState({ - searchTerm: searchTerm.trim().toLowerCase(), + return { + 'aria-label': space.name, + 'aria-roledescription': 'space', + label: space.name, + key: space.id, + prepend: icon, + checked: undefined, + 'data-test-subj': `${space.id}-selectableSpaceItem`, + className: 'selectableSpaceItem', + }; }); }; - - private renderSpaceMenuItem = (space: Space): JSX.Element => { - const LazySpaceAvatar = memo(this.props.spacesApiUi.components.getSpaceAvatar); - const icon = ; // wrapped in a Suspense above - return ( - - {space.name} - - ); - }; } diff --git a/x-pack/plugins/spaces/public/constants.ts b/x-pack/plugins/spaces/public/constants.ts index b513a8affacf82..64781228d4f434 100644 --- a/x-pack/plugins/spaces/public/constants.ts +++ b/x-pack/plugins/spaces/public/constants.ts @@ -16,7 +16,6 @@ export const getSpacesFeatureDescription = () => { 'Organize your dashboards and other saved objects into meaningful categories.', }); } - return spacesFeatureDescription; }; diff --git a/x-pack/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap b/x-pack/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap index 674f5e8b37ca2e..6d99a3526fc7b9 100644 --- a/x-pack/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap +++ b/x-pack/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap @@ -6,16 +6,23 @@ exports[`NavControlPopover renders without crashing 1`] = ` button={ + } closePopover={[Function]} @@ -39,8 +46,9 @@ exports[`NavControlPopover renders without crashing 1`] = ` } } id="headerSpacesMenuContent" + isLoading={false} navigateToApp={[MockFunction]} - onManageSpacesClick={[Function]} + toggleSpaceSelector={[Function]} /> `; diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx index 0292c414e96380..3424bac325d181 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx @@ -12,13 +12,15 @@ import type { FC } from 'react'; import React from 'react'; import type { ApplicationStart, Capabilities } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import { getSpacesFeatureDescription } from '../../constants'; import { ManageSpacesButton } from './manage_spaces_button'; interface Props { id: string; - onManageSpacesClick: () => void; + isLoading: boolean; + toggleSpaceSelector: () => void; capabilities: Capabilities; navigateToApp: ApplicationStart['navigateToApp']; } @@ -30,16 +32,20 @@ export const SpacesDescription: FC = (props: Props) => { title: 'Spaces', }; + const spacesLoadingMessage = i18n.translate('xpack.spaces.navControl.loadingMessage', { + defaultMessage: 'Loading...', + }); + return ( -

{getSpacesFeatureDescription()}

+

{props.isLoading ? spacesLoadingMessage : getSpacesFeatureDescription()}

diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 6f5158423ca517..1d54c639d84d80 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -7,18 +7,23 @@ import './spaces_menu.scss'; +import type { ExclusiveUnion } from '@elastic/eui'; import { - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFieldSearch, - EuiLoadingContent, EuiLoadingSpinner, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelectable, EuiText, } from '@elastic/eui'; -import type { ReactElement } from 'react'; +import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable'; +import type { + EuiSelectableOnChangeEvent, + EuiSelectableSearchableSearchProps, +} from '@elastic/eui/src/components/selectable/selectable'; import React, { Component, lazy, Suspense } from 'react'; import type { ApplicationStart, Capabilities } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import type { InjectedIntl } from '@kbn/i18n-react'; import { FormattedMessage, injectI18n } from '@kbn/i18n-react'; @@ -27,7 +32,6 @@ import { addSpaceIdToPath, ENTER_SPACE_PATH, SPACE_SEARCH_COUNT_THRESHOLD } from import { getSpaceAvatarComponent } from '../../space_avatar'; import { ManageSpacesButton } from './manage_spaces_button'; -// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. const LazySpaceAvatar = lazy(() => getSpaceAvatarComponent().then((component) => ({ default: component })) ); @@ -35,185 +39,153 @@ const LazySpaceAvatar = lazy(() => interface Props { id: string; spaces: Space[]; - isLoading: boolean; serverBasePath: string; - onManageSpacesClick: () => void; + toggleSpaceSelector: () => void; intl: InjectedIntl; capabilities: Capabilities; navigateToApp: ApplicationStart['navigateToApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; + readonly activeSpace: Space | null; } - -interface State { - searchTerm: string; - allowSpacesListFocus: boolean; -} - -class SpacesMenuUI extends Component { - public state = { - searchTerm: '', - allowSpacesListFocus: false, - }; - +class SpacesMenuUI extends Component { public render() { - const { intl, isLoading } = this.props; - const { searchTerm } = this.state; - - const items = isLoading - ? [1, 2, 3].map(this.renderPlaceholderMenuItem) - : this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); - - const panelProps = { - id: this.props.id, - className: 'spcMenu', - title: intl.formatMessage({ - id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', - defaultMessage: 'Change current space', - }), - }; - - if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { - return ( - - {this.renderSearchField()} - {this.renderSpacesListPanel(items, searchTerm)} - {this.renderManageButton()} - - ); - } + const spaceOptions: EuiSelectableOption[] = this.getSpaceOptions(); + + const noSpacesMessage = ( + + + + ); - items.push(this.renderManageButton()); + // In the future this could be replaced by EuiSelectableSearchableProps, but at this time is is not exported from EUI + const searchableProps: ExclusiveUnion< + { searchable: true; searchProps: EuiSelectableSearchableSearchProps<{}> }, + { searchable: false } + > = + this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD + ? { + searchable: true, + searchProps: { + placeholder: i18n.translate( + 'xpack.spaces.navControl.spacesMenu.findSpacePlaceholder', + { + defaultMessage: 'Find a space', + } + ), + compressed: true, + isClearable: true, + id: 'headerSpacesMenuListSearch', + }, + } + : { + searchable: false, + }; - return ; + return ( + <> + + {(list, search) => ( + <> + + {search || + i18n.translate('xpack.spaces.navControl.spacesMenu.selectSpacesTitle', { + defaultMessage: 'Your spaces', + })} + + {list} + + )} + + {this.renderManageButton()} + + ); } - private getVisibleSpaces = (searchTerm: string): Space[] => { - const { spaces } = this.props; - - let filteredSpaces = spaces; - if (searchTerm) { - filteredSpaces = spaces.filter((space) => { - const { name, description = '' } = space; - return ( - name.toLowerCase().indexOf(searchTerm) >= 0 || - description.toLowerCase().indexOf(searchTerm) >= 0 - ); - }); - } - - return filteredSpaces; + private getSpaceOptions = (): EuiSelectableOption[] => { + return this.props.spaces.map((space) => { + return { + 'aria-label': space.name, + 'aria-roledescription': 'space', + label: space.name, + key: space.id, // id is unique and we need it to form a path later + prepend: ( + }> + + + ), + checked: this.props.activeSpace?.id === space.id ? 'on' : undefined, + 'data-test-subj': `${space.id}-selectableSpaceItem`, + className: 'selectableSpaceItem', + }; + }); }; - private renderSpacesListPanel = (items: ReactElement[], searchTerm: string) => { - if (items.length === 0) { - return ( - - - + private spaceSelectionChange = ( + newOptions: EuiSelectableOption[], + event: EuiSelectableOnChangeEvent + ) => { + const selectedSpaceItem = newOptions.filter((item) => item.checked === 'on')[0]; + + if (!!selectedSpaceItem) { + const urlToSelectedSpace = addSpaceIdToPath( + this.props.serverBasePath, + selectedSpaceItem.key, // the key is the unique space id + ENTER_SPACE_PATH ); - } - - return ( - - ); - }; - private renderSearchField = () => { - const { intl } = this.props; - return ( -
- { - - } -
- ); - }; - - private onSearchKeyDown = (e: any) => { - // 9: tab - // 13: enter - // 40: arrow-down - const focusableKeyCodes = [9, 13, 40]; - - const keyCode = e.keyCode; - if (focusableKeyCodes.includes(keyCode)) { - // Allows the spaces list panel to receive focus. This enables keyboard and screen reader navigation - this.setState({ - allowSpacesListFocus: true, - }); + let middleClick = false; + if (event.type === 'click') { + middleClick = (event as React.MouseEvent).button === 1; + } + + if (event.shiftKey) { + // Open in new window, shift is given priority over other modifiers + this.props.toggleSpaceSelector(); + window.open(urlToSelectedSpace); + } else if (event.ctrlKey || event.metaKey || middleClick) { + // Open in new tab - either a ctrl click or middle mouse button + window.open(urlToSelectedSpace, '_blank'); + } else { + // Force full page reload (usually not a good idea, but we need to in order to change spaces) + // If the selected space is already the active space, gracefully close the popover + if (this.props.activeSpace?.id === selectedSpaceItem.key) this.props.toggleSpaceSelector(); + else this.props.navigateToUrl(urlToSelectedSpace); + } } }; - private onSearchFocus = () => { - this.setState({ - allowSpacesListFocus: false, - }); - }; - private renderManageButton = () => { return ( ); }; - - private onSearch = (searchTerm: string) => { - this.setState({ - searchTerm: searchTerm.trim().toLowerCase(), - }); - }; - - private renderSpaceMenuItem = (space: Space): JSX.Element => { - const icon = ( - }> - - - ); - return ( - - {space.name} - - ); - }; - - private renderPlaceholderMenuItem = (key: string | number): JSX.Element => { - return ( - - - - ); - }; } export const SpacesMenu = injectI18n(SpacesMenuUI); diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control.tsx index cf12634ae188e1..80b08bdd5f8f89 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control.tsx @@ -40,6 +40,7 @@ export function initSpacesNavControl(spacesManager: SpacesManager, core: CoreSta anchorPosition="downLeft" capabilities={core.application.capabilities} navigateToApp={core.application.navigateToApp} + navigateToUrl={core.application.navigateToUrl} /> diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx index 8e5aa0c6769fe0..9be4fb5a69e3ba 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx @@ -5,20 +5,68 @@ * 2.0. */ -import { EuiHeaderSectionItemButton } from '@elastic/eui'; -import { waitFor } from '@testing-library/react'; +import { + EuiFieldSearch, + EuiHeaderSectionItemButton, + EuiPopover, + EuiSelectable, + EuiSelectableListItem, +} from '@elastic/eui'; +import { act, waitFor } from '@testing-library/react'; import { shallow } from 'enzyme'; import React from 'react'; import * as Rx from 'rxjs'; import { mountWithIntl } from '@kbn/test-jest-helpers'; +import type { Space } from '../../common'; import { SpaceAvatarInternal } from '../space_avatar/space_avatar_internal'; import type { SpacesManager } from '../spaces_manager'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { NavControlPopover } from './nav_control_popover'; +const mockSpaces = [ + { + id: 'default', + name: 'Default Space', + description: 'this is your default space', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, +]; + describe('NavControlPopover', () => { + async function setup(spaces: Space[]) { + const spacesManager = spacesManagerMock.create(); + spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); + + const wrapper = mountWithIntl( + + ); + + await waitFor(() => { + wrapper.update(); + }); + + return wrapper; + } + it('renders without crashing', () => { const spacesManager = spacesManagerMock.create(); @@ -29,6 +77,7 @@ describe('NavControlPopover', () => { anchorPosition={'downRight'} capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }} navigateToApp={jest.fn()} + navigateToUrl={jest.fn()} /> ); expect(wrapper).toMatchSnapshot(); @@ -36,22 +85,12 @@ describe('NavControlPopover', () => { it('renders a SpaceAvatar with the active space', async () => { const spacesManager = spacesManagerMock.create(); - spacesManager.getSpaces = jest.fn().mockResolvedValue([ - { - id: 'foo-space', - name: 'foo', - disabledFeatures: [], - }, - { - id: 'bar-space', - name: 'bar', - disabledFeatures: [], - }, - ]); + spacesManager.getSpaces = jest.fn().mockResolvedValue(mockSpaces); // @ts-ignore readonly check spacesManager.onActiveSpaceChange$ = Rx.of({ - id: 'foo-space', - name: 'foo', + id: 'default', + name: 'Default Space', + description: 'this is your default space', disabledFeatures: [], }); @@ -62,6 +101,7 @@ describe('NavControlPopover', () => { anchorPosition={'rightCenter'} capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }} navigateToApp={jest.fn()} + navigateToUrl={jest.fn()} /> ); @@ -70,7 +110,117 @@ describe('NavControlPopover', () => { // Wait for `getSpaces` promise to resolve await waitFor(() => { wrapper.update(); - expect(wrapper.find(SpaceAvatarInternal)).toHaveLength(3); + expect(wrapper.find(SpaceAvatarInternal)).toHaveLength(mockSpaces.length + 1); // one additional avatar for the button itself + }); + }); + + it('clicking the button renders an EuiSelectable menu with the provided spaces', async () => { + const wrapper = await setup(mockSpaces); + + await act(async () => { + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + }); + wrapper.update(); + + await act(async () => { + const menu = wrapper.find(EuiSelectable); + expect(menu).toHaveLength(1); + + const items = menu.find(EuiSelectableListItem); + expect(items).toHaveLength(mockSpaces.length); + + mockSpaces.forEach((space, index) => { + const spaceAvatar = items.at(index).find(SpaceAvatarInternal); + expect(spaceAvatar.props().space).toEqual(space); + }); }); }); + + it('should render a search box when there are 8 or more spaces', async () => { + const eightSpaces = mockSpaces.concat([ + { + id: 'space-3', + name: 'Space-3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, + { + id: 'space-6', + name: 'Space 6', + disabledFeatures: [], + }, + { + id: 'space-7', + name: 'Space 7', + disabledFeatures: [], + }, + ]); + const wrapper = await setup(eightSpaces); + + await act(async () => { + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + }); + wrapper.update(); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + }); + + it('should NOT render a search box when there are less than 8 spaces', async () => { + const sevenSpaces = mockSpaces.concat([ + { + id: 'space-3', + name: 'Space-3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, + { + id: 'space-6', + name: 'Space 6', + disabledFeatures: [], + }, + ]); + const wrapper = await setup(sevenSpaces); + + await act(async () => { + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + }); + wrapper.update(); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(0); + }); + + it('can close its popover', async () => { + const wrapper = await setup(mockSpaces); + + await act(async () => { + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + }); + wrapper.update(); + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true); + + await act(async () => { + wrapper.find(EuiPopover).props().closePopover(); + }); + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false); + }); }); diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx index 953c40e9463649..de2c6a062f21c1 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx @@ -11,6 +11,7 @@ import React, { Component, lazy, Suspense } from 'react'; import type { Subscription } from 'rxjs'; import type { ApplicationStart, Capabilities } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import type { Space } from '../../common'; import { getSpaceAvatarComponent } from '../space_avatar'; @@ -28,6 +29,7 @@ interface Props { anchorPosition: PopoverAnchorPosition; capabilities: Capabilities; navigateToApp: ApplicationStart['navigateToApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; serverBasePath: string; } @@ -73,11 +75,12 @@ export class NavControlPopover extends Component { const button = this.getActiveSpaceButton(); let element: React.ReactNode; - if (!this.state.loading && this.state.spaces.length < 2) { + if (this.state.loading || this.state.spaces.length < 2) { element = ( @@ -87,11 +90,12 @@ export class NavControlPopover extends Component { ); } @@ -135,7 +139,7 @@ export class NavControlPopover extends Component { const { activeSpace } = this.state; if (!activeSpace) { - return this.getButton(, 'loading'); + return this.getButton(, 'loading spaces navigation'); } return this.getButton( @@ -152,17 +156,29 @@ export class NavControlPopover extends Component { aria-controls={popoutContentId} aria-expanded={this.state.showSpaceSelector} aria-haspopup="true" - aria-label={linkTitle} + aria-label={i18n.translate('xpack.spaces.navControl.popover.spacesNavigationLabel', { + defaultMessage: 'Spaces navigation', + })} + aria-describedby="spacesNavDetails" data-test-subj="spacesNavSelector" title={linkTitle} onClick={this.toggleSpaceSelector} > {linkIcon} + ); }; - private toggleSpaceSelector = () => { + protected toggleSpaceSelector = () => { const isOpening = !this.state.showSpaceSelector; if (isOpening) { this.loadSpaces(); diff --git a/x-pack/test/functional/apps/spaces/spaces_selection.ts b/x-pack/test/functional/apps/spaces/spaces_selection.ts index 45ae0863de6abd..65fa1c9f00146a 100644 --- a/x-pack/test/functional/apps/spaces/spaces_selection.ts +++ b/x-pack/test/functional/apps/spaces/spaces_selection.ts @@ -36,34 +36,61 @@ export default function spaceSelectorFunctionalTests({ }); this.tags('includeFirefox'); - describe('Space Selector', () => { + describe('Login Space Selector', () => { before(async () => { await PageObjects.security.forceLogout(); }); - afterEach(async () => { + after(async () => { // NOTE: Logout needs to happen before anything else to avoid flaky behavior await PageObjects.security.forceLogout(); }); - it('allows user to navigate to different spaces', async () => { + it('allows user to select initial space', async () => { const spaceId = 'another-space'; await PageObjects.security.login(undefined, undefined, { expectSpaceSelector: true, }); + // select space with card after login await PageObjects.spaceSelector.clickSpaceCard(spaceId); - await PageObjects.spaceSelector.expectHomePage(spaceId); + }); + }); - await PageObjects.spaceSelector.openSpacesNav(); + describe('Space Navigation Menu', () => { + before(async () => { + await PageObjects.security.forceLogout(); + await PageObjects.security.login(undefined, undefined, { + expectSpaceSelector: true, + }); + }); + + after(async () => { + await PageObjects.security.forceLogout(); + }); - // change spaces + it('allows user to navigate to different spaces', async () => { + const anotherSpaceId = 'another-space'; + const defaultSpaceId = 'default'; + const space5Id = 'space-5'; - await PageObjects.spaceSelector.clickSpaceAvatar('default'); + await PageObjects.spaceSelector.clickSpaceCard(defaultSpaceId); + await PageObjects.spaceSelector.expectHomePage(defaultSpaceId); - await PageObjects.spaceSelector.expectHomePage('default'); + // change spaces with nav menu + await PageObjects.spaceSelector.openSpacesNav(); + await PageObjects.spaceSelector.goToSpecificSpace(space5Id); + await PageObjects.spaceSelector.expectHomePage(space5Id); + + await PageObjects.spaceSelector.openSpacesNav(); + await PageObjects.spaceSelector.goToSpecificSpace(anotherSpaceId); + await PageObjects.spaceSelector.expectHomePage(anotherSpaceId); + + await PageObjects.spaceSelector.openSpacesNav(); + await PageObjects.spaceSelector.goToSpecificSpace(defaultSpaceId); + await PageObjects.spaceSelector.expectHomePage(defaultSpaceId); }); }); diff --git a/x-pack/test/functional/page_objects/space_selector_page.ts b/x-pack/test/functional/page_objects/space_selector_page.ts index 9e395ce65e36ed..26ea65e26a9cd6 100644 --- a/x-pack/test/functional/page_objects/space_selector_page.ts +++ b/x-pack/test/functional/page_objects/space_selector_page.ts @@ -38,11 +38,28 @@ export class SpaceSelectorPageObject extends FtrService { this.log.debug(`expectRoute(${spaceId}, ${route})`); await this.find.byCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) ', 20000); const url = await this.browser.getCurrentUrl(); + this.log.debug(`URL: ${url})`); if (spaceId === 'default') { expect(url).to.contain(route); } else { expect(url).to.contain(`/s/${spaceId}${route}`); } + await this.common.sleep(1000); + }); + } + + async expectSpace(spaceId: string) { + return await this.retry.try(async () => { + this.log.debug(`expectSpace(${spaceId}`); + await this.find.byCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) ', 20000); + const url = await this.browser.getCurrentUrl(); + this.log.debug(`URL: ${url})`); + if (spaceId === 'default') { + expect(url).to.not.contain(`/s/${spaceId}`); + } else { + expect(url).to.contain(`/s/${spaceId}`); + } + await this.common.sleep(1000); }); } @@ -51,6 +68,7 @@ export class SpaceSelectorPageObject extends FtrService { return await this.retry.try(async () => { await this.testSubjects.click('spacesNavSelector'); await this.find.byCssSelector('#headerSpacesMenuContent'); + await this.common.sleep(1000); }); } @@ -182,8 +200,12 @@ export class SpaceSelectorPageObject extends FtrService { await this.testSubjects.click('space-avatar-space_b'); } - async goToSpecificSpace(spaceName: string) { - await this.testSubjects.click(`${spaceName}-gotoSpace`); + async goToSpecificSpace(spaceId: string) { + return await this.retry.try(async () => { + this.log.info(`SpaceSelectorPage:goToSpecificSpace(${spaceId})`); + await this.testSubjects.click(`${spaceId}-selectableSpaceItem`); + await this.common.sleep(1000); + }); } async clickSpaceAvatar(spaceId: string) { @@ -208,13 +230,13 @@ export class SpaceSelectorPageObject extends FtrService { } async expectToFindThatManySpace(numberOfExpectedSpace: number) { - const spacesFound = await this.find.allByCssSelector('div[role="dialog"] a.euiContextMenuItem'); + const spacesFound = await this.find.allByCssSelector('div[role="dialog"] li[role="option"]'); expect(spacesFound.length).to.be(numberOfExpectedSpace); } async expectNoSpacesFound() { const msgElem = await this.find.byCssSelector( - 'div[role="dialog"] .euiContextMenuPanel .euiText' + 'div[role="dialog"] div[data-test-subj="euiSelectableMessage"]' ); expect(await msgElem.getVisibleText()).to.be('no spaces found'); } diff --git a/x-pack/test/functional/services/ml/common_ui.ts b/x-pack/test/functional/services/ml/common_ui.ts index 9149f73c05456e..930c925020d56e 100644 --- a/x-pack/test/functional/services/ml/common_ui.ts +++ b/x-pack/test/functional/services/ml/common_ui.ts @@ -330,7 +330,7 @@ export function MachineLearningCommonUIProvider({ async changeToSpace(spaceId: string) { await PageObjects.spaceSelector.openSpacesNav(); await PageObjects.spaceSelector.goToSpecificSpace(spaceId); - await PageObjects.spaceSelector.expectHomePage(spaceId); + await PageObjects.spaceSelector.expectSpace(spaceId); }, async waitForDatePickerIndicatorLoaded() {