Fixes Spaces Menu Keyboard and Screen Reader Navigation (#134454)

* Refactor of spaces pop-over using EuiSelectable.

* Updated to use Eui type instead of interface.

* Reorganization of code, began to add EuiSelectableOnChangeEvent usage.

* Repathed import after rebase

* Integrates the EuiSelectableOnChangeEvent argument to intercept keyboard and mouse event details (shift, ctrl, middle click).

* Resolves missing property error in nav popover test.

* Handles reintroduction of translated strings into empty and no match messages of the Spaces selectable.

* Refactor of spaces_popover_list to use EuiSelectable, resolves keyboard navigation.

* Updated tests for spaces popover list and created more comprehensive tests for nav control popover.
Eliminated tab selection of popover panel itself. Tested screen reader extensively with Chrome on Mac.
Note: Safari screen reader and tabbing behavior is not consistent and differs significantly from Chrome.

* Adjuted focus behavior in popover. Rebased to use EUI 60.1.2

* Fixed typo in roles spaces popover list. Keyboard navigation with options (shift, ctrl, etc.) is not yet working - event argument in selectable onChange is null for keyboard events.

* Updated comments.

* Fixes issue with keyboard operation of manage spaces button of spaces_menu. Fixes naming of translated string resources in spaces_popover_list.

* Fixes name of existing string reference in spaces_menu.

* Fixes CSS selector calls in spaces functional test.

* Fixes space selection in functional tests.

* Fixes popover close issue on Manage spaces button click. Implements check for re-selecting active space. Implements popover close when opening a space in a new window.

* Updated jest snapshot for spaces nav test.

* Fixes ML functional tests to accommodate new behavior when selecting already active space.

* Added active space highlight feedback. Removed state from spaces menu class.

* Rebased, added check mark for active space, resolved activeOption keyboard nav break from EUI update

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Fixes initial active space highlight which was caused by initial empty spaces state in nav_control_popover and loading render logic.
Removes isLoading from spaces_menu - no longer necessary, SpacesDescription is now used during loading.

* Added debug output of actual URL in route expects.

* Added loading message to spaces navigation.

* Updated jest snapshot.

* Addressed flaky test behavior related to space menu nav. Implemented review feedback suggestions.

* Adds sleep to functional test UI interactions.

* Replaced use of any with ExclusiveUnion for search props of EuiSelectable.
Resolved flaky test for spaces nav.

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jeramy Soucy 2022-08-02 10:14:46 -04:00 committed by GitHub
parent ba6ff3bdb0
commit 31c2c0fcb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 567 additions and 349 deletions

View file

@ -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);

View file

@ -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<Props, State> {
public state = {
searchTerm: '',
allowSpacesListFocus: false,
isPopoverOpen: false,
};
@ -56,152 +56,106 @@ export class SpacesPopoverList extends Component<Props, State> {
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
ownFocus
ownFocus={false}
>
{this.getMenuPanel()}
<EuiFocusTrap>{this.getMenuPanel()}</EuiFocusTrap>
</EuiPopover>
);
}
private getMenuPanel = () => {
const { searchTerm } = this.state;
const options = this.getSpaceOptions();
const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem);
const noSpacesMessage = (
<EuiText color="subdued" className="eui-textCenter">
<FormattedMessage
id="xpack.security.management.editRole.spacesPopoverList.noSpacesFoundTitle"
defaultMessage=" no spaces found "
/>
</EuiText>
);
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 (
<EuiContextMenuPanel {...panelProps}>
{this.renderSearchField()}
{this.renderSpacesListPanel(items, searchTerm)}
</EuiContextMenuPanel>
);
}
return <EuiContextMenuPanel {...panelProps} items={items} />;
return (
<EuiSelectable
className={'spcMenu'}
title={i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', {
defaultMessage: 'Spaces',
})}
searchable={this.props.spaces.length >= 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) => (
<>
<EuiPopoverTitle paddingSize="s">
{i18n.translate(
'xpack.security.management.editRole.spacesPopoverList.selectSpacesTitle',
{
defaultMessage: 'Spaces',
}
)}
</EuiPopoverTitle>
{search}
{list}
</>
)}
</EuiSelectable>
);
};
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 renderSpacesListPanel = (items: JSX.Element[], searchTerm: string) => {
if (items.length === 0) {
return (
<EuiText color="subdued" className="eui-textCenter">
<FormattedMessage
id="xpack.security.management.editRole.spacesPopoverList.noSpacesFoundTitle"
defaultMessage=" no spaces found "
/>
</EuiText>
);
}
return (
<EuiContextMenuPanel
key={`spcMenuList`}
data-search-term={searchTerm}
className="spcMenu__spacesList"
initialFocusedItemIndex={this.state.allowSpacesListFocus ? 0 : undefined}
items={items}
/>
);
};
private renderSearchField = () => {
return (
<div key="manageSpacesSearchField" className="spcMenu__searchFieldWrapper">
{
<EuiFieldSearch
placeholder={i18n.translate(
'xpack.security.management.editRole.spacesPopoverList.findSpacePlaceholder',
{
defaultMessage: 'Find a space',
}
)}
incremental={true}
onSearch={this.onSearch}
onKeyDown={this.onSearchKeyDown}
onFocus={this.onSearchFocus}
compressed
/>
}
</div>
);
};
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(),
});
};
private renderSpaceMenuItem = (space: Space): JSX.Element => {
private getSpaceOptions = (): EuiSelectableOption[] => {
const LazySpaceAvatar = memo(this.props.spacesApiUi.components.getSpaceAvatar);
const icon = <LazySpaceAvatar space={space} size={'s'} />; // wrapped in a Suspense above
return (
<EuiContextMenuItem
key={space.id}
icon={icon}
toolTipTitle={space.description && space.name}
toolTipContent={space.description}
>
{space.name}
</EuiContextMenuItem>
);
return this.props.spaces.map((space) => {
const icon = (
<Suspense fallback={<EuiLoadingSpinner size="m" />}>
<LazySpaceAvatar space={space} size={'s'} announceSpaceName={false} />
</Suspense>
);
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',
};
});
};
}

View file

@ -16,7 +16,6 @@ export const getSpacesFeatureDescription = () => {
'Organize your dashboards and other saved objects into meaningful categories.',
});
}
return spacesFeatureDescription;
};

View file

@ -6,16 +6,23 @@ exports[`NavControlPopover renders without crashing 1`] = `
button={
<EuiHeaderSectionItemButton
aria-controls="headerSpacesMenuContent"
aria-describedby="spacesNavDetails"
aria-expanded={false}
aria-haspopup="true"
aria-label="loading"
aria-label="Spaces navigation"
data-test-subj="spacesNavSelector"
onClick={[Function]}
title="loading"
title="loading spaces navigation"
>
<EuiLoadingSpinner
size="m"
/>
<p
hidden={true}
id="spacesNavDetails"
>
loading spaces navigation is the currently selected space. Click this button to open a popover that allows you to select the active space.
</p>
</EuiHeaderSectionItemButton>
}
closePopover={[Function]}
@ -39,8 +46,9 @@ exports[`NavControlPopover renders without crashing 1`] = `
}
}
id="headerSpacesMenuContent"
isLoading={false}
navigateToApp={[MockFunction]}
onManageSpacesClick={[Function]}
toggleSpaceSelector={[Function]}
/>
</EuiPopover>
`;

View file

@ -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: Props) => {
title: 'Spaces',
};
const spacesLoadingMessage = i18n.translate('xpack.spaces.navControl.loadingMessage', {
defaultMessage: 'Loading...',
});
return (
<EuiContextMenuPanel {...panelProps}>
<EuiText className="spcDescription__text">
<p>{getSpacesFeatureDescription()}</p>
<p>{props.isLoading ? spacesLoadingMessage : getSpacesFeatureDescription()}</p>
</EuiText>
<div key="manageSpacesButton" className="spcDescription__manageButtonWrapper">
<ManageSpacesButton
size="s"
style={{ width: `100%` }}
onClick={props.onManageSpacesClick}
onClick={props.toggleSpaceSelector}
capabilities={props.capabilities}
navigateToApp={props.navigateToApp}
/>

View file

@ -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<Props, State> {
public state = {
searchTerm: '',
allowSpacesListFocus: false,
};
class SpacesMenuUI extends Component<Props> {
public render() {
const { intl, isLoading } = this.props;
const { searchTerm } = this.state;
const spaceOptions: EuiSelectableOption[] = this.getSpaceOptions();
const items = isLoading
? [1, 2, 3].map(this.renderPlaceholderMenuItem)
: this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem);
const noSpacesMessage = (
<EuiText color="subdued" className="eui-textCenter">
<FormattedMessage
id="xpack.spaces.navControl.spacesMenu.noSpacesFoundTitle"
defaultMessage=" no spaces found "
/>
</EuiText>
);
const panelProps = {
id: this.props.id,
className: 'spcMenu',
title: intl.formatMessage({
id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle',
defaultMessage: 'Change current space',
}),
};
// 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,
};
if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) {
return (
<EuiContextMenuPanel {...panelProps}>
{this.renderSearchField()}
{this.renderSpacesListPanel(items, searchTerm)}
{this.renderManageButton()}
</EuiContextMenuPanel>
);
}
items.push(this.renderManageButton());
return <EuiContextMenuPanel {...panelProps} items={items} />;
return (
<>
<EuiSelectable
id={this.props.id}
className={'spcMenu'}
title={i18n.translate('xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', {
defaultMessage: 'Change current space',
})}
{...searchableProps}
noMatchesMessage={noSpacesMessage}
options={spaceOptions}
singleSelection={'always'}
style={{ width: 300 }}
onChange={this.spaceSelectionChange}
listProps={{
rowHeight: 40,
showIcons: true,
onFocusBadge: false,
}}
>
{(list, search) => (
<>
<EuiPopoverTitle paddingSize="s">
{search ||
i18n.translate('xpack.spaces.navControl.spacesMenu.selectSpacesTitle', {
defaultMessage: 'Your spaces',
})}
</EuiPopoverTitle>
{list}
</>
)}
</EuiSelectable>
<EuiPopoverFooter paddingSize="s">{this.renderManageButton()}</EuiPopoverFooter>
</>
);
}
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 renderSpacesListPanel = (items: ReactElement[], searchTerm: string) => {
if (items.length === 0) {
return (
<EuiText color="subdued" className="eui-textCenter">
<FormattedMessage
id="xpack.spaces.navControl.spacesMenu.noSpacesFoundTitle"
defaultMessage=" no spaces found "
/>
</EuiText>
);
}
return (
<EuiContextMenuPanel
key={`spcMenuList`}
data-search-term={searchTerm}
className="spcMenu__spacesList"
initialFocusedItemIndex={this.state.allowSpacesListFocus ? 0 : undefined}
items={items}
/>
);
};
private renderSearchField = () => {
const { intl } = this.props;
return (
<div key="manageSpacesSearchField" className="spcMenu__searchFieldWrapper">
{
<EuiFieldSearch
placeholder={intl.formatMessage({
id: 'xpack.spaces.navControl.spacesMenu.findSpacePlaceholder',
defaultMessage: 'Find a space',
})}
incremental={true}
onSearch={this.onSearch}
onKeyDown={this.onSearchKeyDown}
onFocus={this.onSearchFocus}
compressed
/>
}
</div>
);
};
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,
});
}
};
private onSearchFocus = () => {
this.setState({
allowSpacesListFocus: false,
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: (
<Suspense fallback={<EuiLoadingSpinner size="m" />}>
<LazySpaceAvatar space={space} size={'s'} announceSpaceName={false} />
</Suspense>
),
checked: this.props.activeSpace?.id === space.id ? 'on' : undefined,
'data-test-subj': `${space.id}-selectableSpaceItem`,
className: 'selectableSpaceItem',
};
});
};
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
);
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 renderManageButton = () => {
return (
<ManageSpacesButton
key="manageSpacesButton"
className="spcMenu__manageButton"
size="s"
onClick={this.props.onManageSpacesClick}
onClick={this.props.toggleSpaceSelector}
capabilities={this.props.capabilities}
navigateToApp={this.props.navigateToApp}
/>
);
};
private onSearch = (searchTerm: string) => {
this.setState({
searchTerm: searchTerm.trim().toLowerCase(),
});
};
private renderSpaceMenuItem = (space: Space): JSX.Element => {
const icon = (
<Suspense fallback={<EuiLoadingSpinner />}>
<LazySpaceAvatar space={space} size={'s'} />
</Suspense>
);
return (
<EuiContextMenuItem
key={space.id}
data-test-subj={`${space.id}-gotoSpace`}
icon={icon}
href={addSpaceIdToPath(this.props.serverBasePath, space.id, ENTER_SPACE_PATH)}
toolTipTitle={space.description && space.name}
toolTipContent={space.description}
>
<EuiText className="spcMenu__item">{space.name}</EuiText>
</EuiContextMenuItem>
);
};
private renderPlaceholderMenuItem = (key: string | number): JSX.Element => {
return (
<EuiContextMenuItem key={key} disabled={true}>
<EuiLoadingContent lines={1} />
</EuiContextMenuItem>
);
};
}
export const SpacesMenu = injectI18n(SpacesMenuUI);

View file

@ -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}
/>
</Suspense>
</KibanaThemeProvider>

View file

@ -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(
<NavControlPopover
spacesManager={spacesManager as unknown as SpacesManager}
serverBasePath={'/server-base-path'}
anchorPosition={'rightCenter'}
capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }}
navigateToApp={jest.fn()}
navigateToUrl={jest.fn()}
/>
);
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);
});
});

View file

@ -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<Props, State> {
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 = (
<SpacesDescription
id={popoutContentId}
onManageSpacesClick={this.toggleSpaceSelector}
isLoading={this.state.loading}
toggleSpaceSelector={this.toggleSpaceSelector}
capabilities={this.props.capabilities}
navigateToApp={this.props.navigateToApp}
/>
@ -87,11 +90,12 @@ export class NavControlPopover extends Component<Props, State> {
<SpacesMenu
id={popoutContentId}
spaces={this.state.spaces}
isLoading={this.state.loading}
serverBasePath={this.props.serverBasePath}
onManageSpacesClick={this.toggleSpaceSelector}
toggleSpaceSelector={this.toggleSpaceSelector}
capabilities={this.props.capabilities}
navigateToApp={this.props.navigateToApp}
navigateToUrl={this.props.navigateToUrl}
activeSpace={this.state.activeSpace}
/>
);
}
@ -135,7 +139,7 @@ export class NavControlPopover extends Component<Props, State> {
const { activeSpace } = this.state;
if (!activeSpace) {
return this.getButton(<EuiLoadingSpinner size="m" />, 'loading');
return this.getButton(<EuiLoadingSpinner size="m" />, 'loading spaces navigation');
}
return this.getButton(
@ -152,17 +156,29 @@ export class NavControlPopover extends Component<Props, State> {
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}
<p id="spacesNavDetails" hidden>
{i18n.translate('xpack.spaces.navControl.popover.spaceNavigationDetails', {
defaultMessage:
'{space} is the currently selected space. Click this button to open a popover that allows you to select the active space.',
values: {
space: linkTitle,
},
})}
</p>
</EuiHeaderSectionItemButton>
);
};
private toggleSpaceSelector = () => {
protected toggleSpaceSelector = () => {
const isOpening = !this.state.showSpaceSelector;
if (isOpening) {
this.loadSpaces();

View file

@ -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);
});
});
describe('Space Navigation Menu', () => {
before(async () => {
await PageObjects.security.forceLogout();
await PageObjects.security.login(undefined, undefined, {
expectSpaceSelector: true,
});
});
after(async () => {
await PageObjects.security.forceLogout();
});
it('allows user to navigate to different spaces', async () => {
const anotherSpaceId = 'another-space';
const defaultSpaceId = 'default';
const space5Id = 'space-5';
await PageObjects.spaceSelector.clickSpaceCard(defaultSpaceId);
await PageObjects.spaceSelector.expectHomePage(defaultSpaceId);
// 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);
// change spaces
await PageObjects.spaceSelector.clickSpaceAvatar('default');
await PageObjects.spaceSelector.expectHomePage('default');
await PageObjects.spaceSelector.openSpacesNav();
await PageObjects.spaceSelector.goToSpecificSpace(defaultSpaceId);
await PageObjects.spaceSelector.expectHomePage(defaultSpaceId);
});
});

View file

@ -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');
}

View file

@ -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() {