Add no assignees option to user profiles selectable (#140036)

* Add no assignees option to user profiles selectable

* Allow simultaneous selection of no users option and user profiles

* Update filters demo

* Fixing tests because of it getting split into components

* Added suggestions from code review

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
This commit is contained in:
Thom Heymann 2022-09-14 16:52:34 +01:00 committed by GitHub
parent 12acc999c0
commit 40b1a67ae5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 313 additions and 93 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { FunctionComponent, useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { EuiButtonEmpty, EuiFilterGroup, EuiFilterButton, EuiSpacer, EuiTitle } from '@elastic/eui';
import { UserProfilesPopover, UserProfileWithAvatar } from '@kbn/user-profile-components';
import { PanelWithCodeBlock } from './panel_with_code_block';
@ -57,6 +57,44 @@ export const PopoverDemo: FunctionComponent = () => {
},
];
const [isOpen2, setIsOpen2] = useState(false);
const [selectedOptions2, setSelectedOptions2] = useState<Array<UserProfileWithAvatar | null>>([
null,
]);
const options2: Array<UserProfileWithAvatar | null> = [
null,
{
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
enabled: true,
data: {},
user: {
username: 'damaged_raccoon',
email: 'damaged_raccoon@elastic.co',
full_name: 'Damaged Raccoon',
},
},
{
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
enabled: true,
data: {},
user: {
username: 'physical_dinosaur',
email: 'physical_dinosaur@elastic.co',
full_name: 'Physical Dinosaur',
},
},
{
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
enabled: true,
data: {},
user: {
username: 'wet_dingo',
email: 'wet_dingo@elastic.co',
full_name: 'Wet Dingo',
},
},
];
return (
<PanelWithCodeBlock title="Popover" code={code}>
<UserProfilesPopover
@ -78,6 +116,38 @@ export const PopoverDemo: FunctionComponent = () => {
width: 32 * 16,
}}
/>
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h3>Unassigned option</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFilterGroup>
<UserProfilesPopover
button={
<EuiFilterButton
iconType="arrowDown"
numFilters={options2.length}
hasActiveFilters={selectedOptions2.length > 0}
numActiveFilters={selectedOptions2.length}
onClick={() => setIsOpen2((value) => !value)}
>
Assignees
</EuiFilterButton>
}
isOpen={isOpen2}
closePopover={() => setIsOpen2(false)}
selectableProps={{
selectedOptions: selectedOptions2,
options: options2,
onChange: setSelectedOptions2,
height: 32 * 8,
nullOptionLabel: 'Unassigned',
}}
panelStyle={{
width: 32 * 16,
}}
/>
</EuiFilterGroup>
</PanelWithCodeBlock>
);
};

View file

@ -7,16 +7,17 @@
*/
import type { EuiPopoverProps, EuiContextMenuPanelProps } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { EuiPopover, EuiContextMenuPanel, useGeneratedHtmlId } from '@elastic/eui';
import { UserProfilesSelectable, UserProfilesSelectableProps } from './user_profiles_selectable';
import type { UserProfileWithAvatar } from './user_avatar';
/**
* Props of {@link UserProfilesPopover} component
*/
export interface UserProfilesPopoverProps extends EuiPopoverProps {
export interface UserProfilesPopoverProps<Option extends UserProfileWithAvatar | null>
extends EuiPopoverProps {
/**
* Title of the popover
* @see EuiContextMenuPanelProps
@ -27,17 +28,17 @@ export interface UserProfilesPopoverProps extends EuiPopoverProps {
* Props forwarded to selectable component
* @see UserProfilesSelectableProps
*/
selectableProps: UserProfilesSelectableProps;
selectableProps: UserProfilesSelectableProps<Option>;
}
/**
* Renders a selectable component inside a popover given a list of user profiles
*/
export const UserProfilesPopover: FunctionComponent<UserProfilesPopoverProps> = ({
export const UserProfilesPopover = <Option extends UserProfileWithAvatar | null>({
title,
selectableProps,
...popoverProps
}) => {
}: UserProfilesPopoverProps<Option>) => {
const searchInputId = useGeneratedHtmlId({
prefix: 'searchInput',
conditionalId: selectableProps.searchInputId,

View file

@ -199,11 +199,73 @@ describe('UserProfilesSelectable', () => {
const onSearchChange = jest.fn();
const wrapper = mount(<UserProfilesSelectable onSearchChange={onSearchChange} />);
wrapper.find('input[type="search"]').simulate('change', { target: { value: 'search' } });
expect(onSearchChange).toHaveBeenCalledWith('search', []);
expect(onSearchChange).toHaveBeenCalledWith('search');
});
it('should set `id` prop of search input correctly', () => {
const wrapper = mount(<UserProfilesSelectable searchInputId="testSearchField" />);
expect(wrapper.find('input[type="search"]').prop('id')).toBe('testSearchField');
});
describe('with "no users" option', () => {
it('should render `null` option correctly', () => {
const [firstOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable selectedOptions={[null]} options={[null, firstOption]} />
);
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: 'null',
checked: 'on',
}),
expect.objectContaining({
key: firstOption.uid,
checked: undefined,
}),
]);
});
it('should trigger `onChange` callback with `null` when "no users" get selected', () => {
const onChange = jest.fn();
const [firstOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable options={[null, firstOption]} onChange={onChange} />
);
wrapper.find('EuiSelectableListItem').first().simulate('click');
expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([null]));
});
it('should trigger `onChange` callback with empty array when nothing gets selected', () => {
const onChange = jest.fn();
const [firstOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable
selectedOptions={[null]}
options={[null, firstOption]}
onChange={onChange}
/>
);
wrapper.find('EuiSelectableListItem').first().simulate('click');
expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([]));
});
it('should trigger `onChange` callback with selected option when selected', () => {
const onChange = jest.fn();
const [firstOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable options={[null, firstOption]} onChange={onChange} />
);
wrapper.find('EuiSelectableListItem').last().simulate('click');
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
uid: firstOption.uid,
}),
])
);
});
});
});

View file

@ -16,9 +16,10 @@ import {
EuiSelectable,
EuiSpacer,
EuiText,
EuiHighlight,
EuiTextColor,
} from '@elastic/eui';
import type { FunctionComponent, ReactNode } from 'react';
import type { ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
@ -28,10 +29,12 @@ import { getUserDisplayName } from './user_profile';
import type { UserProfileWithAvatar } from './user_avatar';
import { UserAvatar } from './user_avatar';
const NULL_OPTION_KEY = 'null';
/**
* Props of {@link UserProfilesSelectable} component
*/
export interface UserProfilesSelectableProps
export interface UserProfilesSelectableProps<Option extends UserProfileWithAvatar | null>
extends Pick<
EuiSelectableProps,
| 'height'
@ -44,23 +47,23 @@ export interface UserProfilesSelectableProps
/**
* List of users to be rendered as suggestions.
*/
defaultOptions?: UserProfileWithAvatar[];
defaultOptions?: Option[];
/**
* List of selected users.
* List of selected users or `null` (no users).
*/
selectedOptions?: UserProfileWithAvatar[];
selectedOptions?: Option[];
/**
* List of users from search results. Should be updated based on the search term provided by `onSearchChange` callback.
*/
options?: UserProfileWithAvatar[];
options?: Option[];
/**
* Passes back the list of selected users.
* @param options List of selected users
* Passes back the current selection.
* @param options Either the list of selected users or `null` (no users).
*/
onChange?(options: UserProfileWithAvatar[]): void;
onChange?(options: Option[]): void;
/**
* Passes back the search term.
@ -90,15 +93,25 @@ export interface UserProfilesSelectableProps
selectedStatusMessage?(selectedCount: number): ReactNode;
/**
* Text for label of clear button.
* Label for clear button.
*/
clearButtonLabel?: ReactNode;
/**
* Label of "no users" option.
*/
nullOptionLabel?: string;
/**
* Label for default options group separator.
*/
defaultOptionsLabel?: string;
}
/**
* Renders a selectable component given a list of user profiles
*/
export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectableProps> = ({
export const UserProfilesSelectable = <Option extends UserProfileWithAvatar | null>({
selectedOptions,
defaultOptions,
options,
@ -114,14 +127,17 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
searchPlaceholder,
searchInputId,
selectedStatusMessage,
nullOptionLabel,
defaultOptionsLabel,
clearButtonLabel,
}) => {
}: UserProfilesSelectableProps<Option>) => {
const [displayedOptions, setDisplayedOptions] = useState<SelectableOption[]>([]);
const [searchTerm, setSearchTerm] = useState('');
// Resets all displayed options
const resetDisplayedOptions = () => {
if (options) {
setDisplayedOptions(options.map(toSelectableOption));
setDisplayedOptions(options.map((option) => toSelectableOption(option, nullOptionLabel)));
return;
}
@ -133,11 +149,13 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
let index = values.findIndex((option) => option.isGroupLabel);
if (index === -1) {
const length = values.push({
label: i18n.translate('userProfileComponents.userProfilesSelectable.suggestedLabel', {
defaultMessage: 'Suggested',
}),
label:
defaultOptionsLabel ??
i18n.translate('userProfileComponents.userProfilesSelectable.defaultOptionsLabel', {
defaultMessage: 'Suggested',
}),
isGroupLabel: true,
} as SelectableOption);
});
index = length - 1;
}
return index;
@ -156,8 +174,8 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
// Get any newly added selected options
const selectedOptionsToAdd: SelectableOption[] = selectedOptions
? selectedOptions
.filter((profile) => !nextOptions.find((option) => option.key === profile.uid))
.map(toSelectableOption)
.filter((profile) => !nextOptions.find((option) => isMatchingOption(option, profile)))
.map((option) => toSelectableOption(option, nullOptionLabel))
: [];
// Get any newly added default options
@ -165,10 +183,10 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
? defaultOptions
.filter(
(profile) =>
!nextOptions.find((option) => option.key === profile.uid) &&
!selectedOptionsToAdd.find((option) => option.key === profile.uid)
!nextOptions.find((option) => isMatchingOption(option, profile)) &&
!selectedOptionsToAdd.find((option) => isMatchingOption(option, profile))
)
.map(toSelectableOption)
.map((option) => toSelectableOption(option, nullOptionLabel))
: [];
// Merge in any new options and add group separator if necessary
@ -189,8 +207,8 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
setDisplayedOptions((values) =>
values.map((option) => {
if (selectedOptions) {
const match = selectedOptions.find((p) => p.uid === option.key);
return { ...option, checked: match ? 'on' : undefined };
const match = selectedOptions.find((profile) => isMatchingOption(option, profile));
return { ...option, checked: match === undefined ? undefined : 'on' };
}
return { ...option, checked: undefined };
})
@ -207,30 +225,40 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
<EuiSelectable
options={displayedOptions}
// @ts-expect-error: Type of `nextOptions` in EuiSelectable does not match what's actually being passed back so need to manually override it
onChange={(nextOptions: Array<EuiSelectableOption<{ data: UserProfileWithAvatar }>>) => {
onChange={(
nextOptions: Array<EuiSelectableOption<{ data: Partial<UserProfileWithAvatar> }>>
) => {
if (!onChange) {
return;
}
// Take all selected options from `nextOptions` unless already in `props.selectedOptions`
const values: UserProfileWithAvatar[] = nextOptions
// @ts-expect-error
const values: Option[] = nextOptions
.filter((option) => {
if (option.isGroupLabel || option.checked !== 'on') {
return false;
}
if (selectedOptions && selectedOptions.find((p) => p.uid === option.key)) {
if (
selectedOptions &&
selectedOptions.find((profile) => isMatchingOption(option, profile)) !== undefined
) {
return false;
}
return true;
})
.map((option) => option.data);
.map((option) => (option.key === NULL_OPTION_KEY ? null : option.data));
// Add all options from `props.selectedOptions` unless they have been deselected in `nextOptions`
if (selectedOptions && !singleSelection) {
selectedOptions.forEach((profile) => {
const match = nextOptions.find((o) => o.key === profile.uid);
if (!match || match.checked === 'on') {
values.push(profile);
const match = nextOptions.find((option) => isMatchingOption(option, profile));
if (match === undefined || match.checked === 'on') {
if (match && match.key === NULL_OPTION_KEY) {
values.unshift(profile);
} else {
values.push(profile);
}
}
});
}
@ -246,7 +274,11 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
i18n.translate('userProfileComponents.userProfilesSelectable.searchPlaceholder', {
defaultMessage: 'Search',
}),
onChange: onSearchChange,
value: searchTerm,
onChange: (value) => {
setSearchTerm(value);
onSearchChange?.(value);
},
isLoading,
isClearable: !isLoading,
id: searchInputId,
@ -257,44 +289,81 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
noMatchesMessage={noMatchesMessage}
emptyMessage={emptyMessage}
errorMessage={errorMessage}
renderOption={(option, searchValue) => {
if (option.user) {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
gutterSize="s"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiHighlight search={searchValue}>{option.label}</EuiHighlight>
</EuiFlexItem>
{option.user.email ? (
<EuiFlexItem grow={false}>
<EuiTextColor color="subdued">
{searchValue ? (
<EuiHighlight search={searchValue}>{option.user.email}</EuiHighlight>
) : (
option.user.email
)}
</EuiTextColor>
</EuiFlexItem>
) : undefined}
</EuiFlexGroup>
);
}
return <EuiHighlight search={searchValue}>{option.label}</EuiHighlight>;
}}
>
{(list, search) => (
<>
<EuiPanel hasShadow={false} paddingSize="s">
{search}
<EuiSpacer size="s" />
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{selectedStatusMessage ? (
selectedStatusMessage(selectedCount)
) : (
<FormattedMessage
id="userProfileComponents.userProfilesSelectable.selectedStatusMessage"
defaultMessage="{count, plural, one {# user selected} other {# users selected}}"
values={{ count: selectedCount }}
/>
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{selectedCount ? (
<EuiButtonEmpty
size="xs"
flush="right"
onClick={() => onChange?.([])}
style={{ height: '1rem' }}
>
{clearButtonLabel ?? (
<FormattedMessage
id="userProfileComponents.userProfilesSelectable.clearButtonLabel"
defaultMessage="Remove all users"
/>
)}
</EuiButtonEmpty>
) : undefined}
</EuiFlexItem>
</EuiFlexGroup>
{!singleSelection ? (
<>
<EuiSpacer size="s" />
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
gutterSize="s"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{selectedStatusMessage ? (
selectedStatusMessage(selectedCount)
) : (
<FormattedMessage
id="userProfileComponents.userProfilesSelectable.selectedStatusMessage"
defaultMessage="{count, plural, one {# user selected} other {# users selected}}"
values={{ count: selectedCount }}
/>
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{selectedCount ? (
<EuiButtonEmpty
size="xs"
flush="right"
onClick={() => onChange?.([])}
style={{ height: '1rem' }}
>
{clearButtonLabel ?? (
<FormattedMessage
id="userProfileComponents.userProfilesSelectable.clearButtonLabel"
defaultMessage="Remove all users"
/>
)}
</EuiButtonEmpty>
) : undefined}
</EuiFlexItem>
</EuiFlexGroup>
</>
) : undefined}
</EuiPanel>
<EuiHorizontalRule margin="none" />
{list}
@ -304,15 +373,33 @@ export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectablePro
);
};
type SelectableOption = EuiSelectableOption<UserProfileWithAvatar>;
type SelectableOption = EuiSelectableOption<Partial<UserProfileWithAvatar>>;
function toSelectableOption(userProfile: UserProfileWithAvatar): SelectableOption {
// @ts-ignore: `isGroupLabel` is not required here but TS complains
function toSelectableOption(
userProfile: UserProfileWithAvatar | null,
nullOptionLabel?: string
): SelectableOption {
if (userProfile) {
return {
key: userProfile.uid,
prepend: <UserAvatar user={userProfile.user} avatar={userProfile.data.avatar} size="s" />,
label: getUserDisplayName(userProfile.user),
data: userProfile,
};
}
return {
key: userProfile.uid,
prepend: <UserAvatar user={userProfile.user} avatar={userProfile.data.avatar} size="s" />,
label: getUserDisplayName(userProfile.user),
append: <EuiTextColor color="subdued">{userProfile.user.email}</EuiTextColor>,
data: userProfile,
key: NULL_OPTION_KEY,
label:
nullOptionLabel ??
i18n.translate('userProfileComponents.userProfilesSelectable.nullOptionLabel', {
defaultMessage: 'No users',
}),
};
}
function isMatchingOption<Option extends UserProfileWithAvatar | null>(
option: SelectableOption,
profile: Option
) {
return option.key === (profile ? profile.uid : NULL_OPTION_KEY);
}

View file

@ -44,7 +44,7 @@ describe('AssigneesFilterPopover', () => {
await waitForEuiPopoverOpen();
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } });
userEvent.click(screen.getByText('wet_dingo@elastic.co'));
userEvent.click(screen.getByText('WD'));
expect(onSelectionChange.mock.calls[0][0]).toMatchInlineSnapshot(`
Array [
@ -75,7 +75,7 @@ describe('AssigneesFilterPopover', () => {
await waitForEuiPopoverOpen();
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } });
userEvent.click(screen.getByText('wet_dingo@elastic.co'));
userEvent.click(screen.getByText('WD'));
userEvent.click(screen.getByText('damaged_raccoon@elastic.co'));
expect(onSelectionChange.mock.calls[0][0]).toMatchInlineSnapshot(`

View file

@ -44,10 +44,10 @@ describe('SuggestUsersPopover', () => {
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } });
await waitFor(() => {
expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument();
expect(screen.getByText('WD')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('wet_dingo@elastic.co'));
fireEvent.click(screen.getByText('WD'));
expect(onUsersChange.mock.calls[0][0]).toMatchInlineSnapshot(`
Array [
@ -75,12 +75,12 @@ describe('SuggestUsersPopover', () => {
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'elastic' } });
await waitFor(() => {
expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument();
expect(screen.getByText('damaged_raccoon@elastic.co')).toBeInTheDocument();
expect(screen.getByText('WD')).toBeInTheDocument();
expect(screen.getByText('DR')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('wet_dingo@elastic.co'));
fireEvent.click(screen.getByText('damaged_raccoon@elastic.co'));
fireEvent.click(screen.getByText('WD'));
fireEvent.click(screen.getByText('DR'));
expect(onUsersChange.mock.calls[1][0]).toMatchInlineSnapshot(`
Array [
@ -123,10 +123,10 @@ describe('SuggestUsersPopover', () => {
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'elastic' } });
await waitFor(() => {
expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument();
expect(screen.getByText('WD')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('wet_dingo@elastic.co'));
fireEvent.click(screen.getByText('WD'));
expect(onUsersChange.mock.calls[0][0]).toMatchInlineSnapshot(`
Array [
@ -173,10 +173,10 @@ describe('SuggestUsersPopover', () => {
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } });
await waitFor(() => {
expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument();
expect(screen.getByText('WD')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('wet_dingo@elastic.co'));
fireEvent.click(screen.getByText('WD'));
expect(screen.getByText('1 assigned')).toBeInTheDocument();
});
@ -187,7 +187,7 @@ describe('SuggestUsersPopover', () => {
expect(screen.queryByText('assigned')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } });
fireEvent.click(screen.getByText('wet_dingo@elastic.co'));
fireEvent.click(screen.getByText('WD'));
expect(screen.getByText('1 assigned')).toBeInTheDocument();
});