[Shared UX] Migrate avatar user profile component from packages/kbn-user-profile-components to Shared UX (#139247)

This commit is contained in:
Rachel Shen 2022-09-19 13:18:31 -06:00 committed by GitHub
parent 93bfec9799
commit 7734761bc1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1280 additions and 0 deletions

View file

@ -340,6 +340,7 @@
"@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository",
"@kbn/shared-svg": "link:bazel-bin/packages/kbn-shared-svg",
"@kbn/shared-ux-avatar-solution": "link:bazel-bin/packages/shared-ux/avatar/solution",
"@kbn/shared-ux-avatar-user-profile-components": "link:bazel-bin/packages/shared-ux/avatar/user_profile/impl",
"@kbn/shared-ux-button-exit-full-screen": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/impl",
"@kbn/shared-ux-button-exit-full-screen-mocks": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/mocks",
"@kbn/shared-ux-button-exit-full-screen-types": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/types",
@ -1065,6 +1066,7 @@
"@types/kbn__server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository/npm_module_types",
"@types/kbn__shared-svg": "link:bazel-bin/packages/kbn-shared-svg/npm_module_types",
"@types/kbn__shared-ux-avatar-solution": "link:bazel-bin/packages/shared-ux/avatar/solution/npm_module_types",
"@types/kbn__shared-ux-avatar-user-profile-components": "link:bazel-bin/packages/shared-ux/avatar/user_profile/impl/npm_module_types",
"@types/kbn__shared-ux-button-exit-full-screen": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/impl/npm_module_types",
"@types/kbn__shared-ux-button-exit-full-screen-mocks": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/mocks/npm_module_types",
"@types/kbn__shared-ux-button-exit-full-screen-types": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/types/npm_module_types",

View file

@ -0,0 +1,136 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "avatar-user-profile"
PKG_REQUIRE_NAME = "@kbn/shared-ux-avatar-user-profile-components"
SOURCE_FILES = glob(
[
"**/*.ts",
"**/*.tsx",
"**/*.mdx",
],
exclude = [
"**/*.test.*",
"**/*.stories.*",
],
)
SRCS = SOURCE_FILES
# filegroup(
# name = "srcs",
# srcs = SRCS,
# )
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
# In this array place runtime dependencies, including other packages and NPM packages
# which must be available for this code to run.
#
# To reference other packages use:
# "//repo/relative/path/to/package"
# eg. "//packages/kbn-utils"
#
# To reference a NPM package use:
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
"@npm//react",
"@npm//@elastic/eui",
"//packages/kbn-i18n-react",
"//packages/kbn-i18n",
"//packages/kbn-shared-ux-utility",
"//packages/kbn-ambient-ui-types",
]
# In this array place dependencies necessary to build the types, which will include the
# :npm_module_types target of other packages and packages from NPM, including @types/*
# packages.
#
# To reference the types for another package use:
# "//repo/relative/path/to/package:npm_module_types"
# eg. "//packages/kbn-utils:npm_module_types"
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"@npm//@types/node",
"@npm//@types/jest",
"@npm//@types/react",
"@npm//@elastic/eui",
"//packages/kbn-i18n-react:npm_module_types",
"//packages/kbn-i18n:npm_module_types",
"//packages/kbn-shared-ux-utility:npm_module_types",
"//packages/kbn-ambient-ui-types",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
jsts_transpiler(
name = "target_web",
srcs = SRCS,
build_pkg_name = package_name(),
web = True,
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
declaration_map = True,
emit_declaration_only = True,
out_dir = "target_types",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node", ":target_web"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,12 @@
---
id: sharedUX/Components/UserProfileAvatar
slug: /shared-ux/components/user-profile-avatar
title: User Profile Avatar
description: A wrapper around `EuiAvatar`
tags: ['shared-ux', 'component']
date: 2022-09-01
---
## Description
A wrapper around `EuiAvatar` tailored for user profiles

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { UserAvatarProps, UserProfileWithAvatar } from './user_avatar';
export type { UserProfilesSelectableProps } from './user_profiles_selectable';
export type { UserProfilesPopoverProps } from './user_profiles_popover';
export { UserAvatar } from './user_avatar';
export { UserProfilesSelectable } from './user_profiles_selectable';
export { UserProfilesPopover } from './user_profiles_popover';
export type { UserProfile, UserProfileUserInfo, UserProfileAvatarData } from './user_profile';

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/packages/shared-ux/avatar/user_profile/impl'],
};

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/shared-ux-avatar-user-profile-components",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"browser": "./target_web/index.js",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,22 @@
{
"extends": "../../../../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"stripInternal": false,
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types"
]
},
"include": [
"*ts*",
"*.md*",
"**/*.ts",
"**/*.md*",
]
}

View file

@ -0,0 +1,94 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { UserAvatar } from './user_avatar';
describe('UserAvatar', () => {
it('should render `EuiAvatar` correctly with image avatar', () => {
const wrapper = shallow(
<UserAvatar
user={{
username: 'delighted_nightingale',
email: 'delighted_nightingale@elastic.co',
fullName: 'Delighted Nightingale',
}}
avatar={{
color: '#09e8ca',
initials: 'DN',
imageUrl: 'https://source.unsplash.com/64x64/?cat',
}}
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiAvatar
color="plain"
imageUrl="https://source.unsplash.com/64x64/?cat"
name="Delighted Nightingale"
/>
`);
});
it('should render `EuiAvatar` correctly with initials avatar', () => {
const wrapper = shallow(
<UserAvatar
user={{
username: 'delighted_nightingale',
email: 'delighted_nightingale@elastic.co',
fullName: 'Delighted Nightingale',
}}
avatar={{
color: '#09e8ca',
initials: 'DN',
imageUrl: undefined,
}}
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiAvatar
color="#09e8ca"
initials="DN"
initialsLength={2}
name="Delighted Nightingale"
/>
`);
});
it('should render `EuiAvatar` correctly without avatar data', () => {
const wrapper = shallow(
<UserAvatar
user={{
username: 'delighted_nightingale',
email: 'delighted_nightingale@elastic.co',
fullName: 'Delighted Nightingale',
}}
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiAvatar
color="#AA6556"
initials="DN"
initialsLength={2}
name="Delighted Nightingale"
/>
`);
});
it('should render `EuiAvatar` correctly without user data', () => {
const wrapper = shallow(<UserAvatar />);
expect(wrapper).toMatchInlineSnapshot(`
<EuiAvatar
color="#F1F4FA"
initials="?"
name=""
/>
`);
});
});

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiAvatarProps } from '@elastic/eui';
import { EuiAvatar, useEuiTheme } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import type { UserProfile, UserProfileUserInfo, UserProfileAvatarData } from './user_profile';
import {
getUserAvatarColor,
getUserAvatarInitials,
getUserDisplayName,
USER_AVATAR_MAX_INITIALS,
} from './user_profile';
/**
* Convenience type for a {@link UserProfile} with avatar data
*/
export type UserProfileWithAvatar = UserProfile<{ avatar?: UserProfileAvatarData }>;
/**
* Props of {@link UserAvatar} component
*/
export interface UserAvatarProps
extends Omit<
EuiAvatarProps,
| 'initials'
| 'initialsLength'
| 'imageUrl'
| 'iconType'
| 'iconSize'
| 'iconColor'
| 'name'
| 'color'
| 'type'
> {
/**
* User to be rendered
*/
user?: UserProfileUserInfo;
/**
* Avatar data of user to be rendered
*/
avatar?: UserProfileAvatarData;
}
/**
* Renders an avatar given a user profile
*/
export const UserAvatar: FunctionComponent<UserAvatarProps> = ({ user, avatar, ...rest }) => {
const { euiTheme } = useEuiTheme();
if (!user) {
return <EuiAvatar name="" color={euiTheme.colors.lightestShade} initials="?" {...rest} />;
}
const displayName = getUserDisplayName(user);
if (avatar?.imageUrl) {
return <EuiAvatar name={displayName} imageUrl={avatar.imageUrl} color="plain" {...rest} />;
}
return (
<EuiAvatar
name={displayName}
initials={getUserAvatarInitials(user, avatar)}
initialsLength={USER_AVATAR_MAX_INITIALS}
color={getUserAvatarColor(user, avatar)}
{...rest}
/>
);
};

View file

@ -0,0 +1,137 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { VISUALIZATION_COLORS } from '@elastic/eui';
/**
* IMPORTANT:
*
* The types in this file have been imported from
* `x-pack/plugins/security/common/model/user_profile.ts`
*
* When making changes please ensure to keep both files in sync.
*/
/**
* Describes basic properties stored in user profile.
*/
export interface UserProfile<D extends UserProfileData = UserProfileData> {
/**
* Unique ID for of the user profile.
*/
uid: string;
/**
* Indicates whether user profile is enabled or not.
*/
enabled: boolean;
/**
* Information about the user that owns profile.
*/
user: UserProfileUserInfo;
/**
* User specific data associated with the profile.
*/
data: Partial<D>;
}
/**
* Basic user information returned in user profile.
*/
export interface UserProfileUserInfo {
/**
* Username of the user.
*/
username: string;
/**
* Optional email of the user.
*/
email?: string;
/**
* Optional full name of the user.
*/
fullName?: string;
/**
* Optional display name of the user.
*/
displayName?: string;
}
/**
* Placeholder for data stored in user profile.
*/
export type UserProfileData = Record<string, unknown>;
/**
* Avatar stored in user profile.
*/
export interface UserProfileAvatarData {
/**
* Optional initials (two letters) of the user to use as avatar if avatar picture isn't specified.
*/
initials?: string;
/**
* Background color of the avatar when initials are used.
*/
color?: string;
/**
* Base64 data URL for the user avatar image.
*/
imageUrl?: string;
}
export const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a"
export const USER_AVATAR_MAX_INITIALS = 2;
/**
* Determines the color for the provided user profile.
* If a color is present on the user profile itself, then that is used.
* Otherwise, a color is provided from EUI's Visualization Colors based on the display name.
*
* @param {UserProfileUserInfo} user User info
* @param {UserProfileAvatarData} avatar User avatar
*/
export function getUserAvatarColor(
user: Pick<UserProfileUserInfo, 'username' | 'fullName'>,
avatar?: UserProfileAvatarData
) {
const firstCodePoint = getUserDisplayName(user).codePointAt(0) || USER_AVATAR_FALLBACK_CODE_POINT;
return avatar?.color ?? VISUALIZATION_COLORS[firstCodePoint % VISUALIZATION_COLORS.length];
}
/**
* Determines the initials for the provided user profile.
* If initials are present on the user profile itself, then that is used.
* Otherwise, the initials are calculated based off the words in the display name, with a max length of 2 characters.
*
* @param {UserProfileUserInfo} user User info
* @param {UserProfileAvatarData} avatar User avatar
*/
export function getUserAvatarInitials(
user: Pick<UserProfileUserInfo, 'username' | 'fullName'>,
avatar?: UserProfileAvatarData
) {
const words = getUserDisplayName(user).split(' ');
const numInitials = Math.min(USER_AVATAR_MAX_INITIALS, words.length);
words.splice(numInitials, words.length);
return avatar?.initials ?? words.map((word) => word.substring(0, 1)).join('');
}
/**
* Determines the display name for the provided user profile.
*
* @param {UserProfileUserInfo} user User info
*/
export function getUserDisplayName(user: Pick<UserProfileUserInfo, 'username' | 'fullName'>) {
return user.fullName || user.username;
}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { UserAvatar, UserAvatarProps } from './user_avatar';
import mdx from './README.mdx';
import { UserProfileUserInfo } from './user_profile';
export default {
title: 'Avatar/User Profile',
description: '',
parameters: {
docs: {
page: mdx,
},
},
};
type UserAvatarParams = Pick<UserAvatarProps, 'user'>;
const sampleUsers = [
{
username: 'Peggy',
email: 'test@email.com',
fullName: 'Peggy Simms',
displayName: 'Peggy',
},
{
username: 'Martin',
email: 'test@email.com',
fullName: 'Martin Gatsby',
displayName: 'Martin',
},
{
username: 'Leonardo DiCaprio',
email: 'test@email.com',
fullName: 'Leonardo DiCaprio',
displayName: 'Leonardo DiCaprio',
},
];
export const userAvatar = (
params: Pick<UserProfileUserInfo, 'username'>,
rest: UserAvatarParams
) => {
const username = params;
return <UserAvatar {...{ user: username }} {...rest} />;
};
userAvatar.argTypes = {
username: {
control: { type: 'radio' },
options: sampleUsers.map(({ username }) => username),
defaultValue: sampleUsers.map(({ username }) => username)[0],
},
};

View file

@ -0,0 +1,124 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { UserProfilesPopover } from './user_profiles_popover';
const userProfiles = [
{
uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0',
enabled: true,
data: {},
user: {
username: 'delighted_nightingale',
email: 'delighted_nightingale@profiles.elastic.co',
fullName: 'Delighted Nightingale',
},
},
{
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
enabled: true,
data: {},
user: {
username: 'damaged_raccoon',
email: 'damaged_raccoon@profiles.elastic.co',
fullName: 'Damaged Raccoon',
},
},
{
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
enabled: true,
data: {},
user: {
username: 'physical_dinosaur',
email: 'physical_dinosaur@profiles.elastic.co',
fullName: 'Physical Dinosaur',
},
},
{
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
enabled: true,
data: {},
user: {
username: 'wet_dingo',
email: 'wet_dingo@profiles.elastic.co',
fullName: 'Wet Dingo',
},
},
];
describe('UserProfilesPopover', () => {
it('should render `EuiPopover` and `UserProfilesSelectable` correctly', () => {
const [firstOption, secondOption] = userProfiles;
const wrapper = shallow(
<UserProfilesPopover
title="Title"
button={<button>Toggle</button>}
closePopover={jest.fn()}
selectableProps={{
selectedOptions: [firstOption],
defaultOptions: [secondOption],
}}
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiPopover
anchorPosition="downCenter"
button={
<button>
Toggle
</button>
}
closePopover={[MockFunction]}
display="inline-block"
hasArrow={true}
isOpen={false}
ownFocus={true}
panelPaddingSize="none"
>
<EuiContextMenuPanel
items={Array []}
title="Title"
>
<UserProfilesSelectable
defaultOptions={
Array [
Object {
"data": Object {},
"enabled": true,
"uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"user": Object {
"email": "damaged_raccoon@profiles.elastic.co",
"fullName": "Damaged Raccoon",
"username": "damaged_raccoon",
},
},
]
}
selectedOptions={
Array [
Object {
"data": Object {},
"enabled": true,
"uid": "u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0",
"user": Object {
"email": "delighted_nightingale@profiles.elastic.co",
"fullName": "Delighted Nightingale",
"username": "delighted_nightingale",
},
},
]
}
/>
</EuiContextMenuPanel>
</EuiPopover>
`);
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiPopoverProps, EuiContextMenuPanelProps } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { EuiPopover, EuiContextMenuPanel } from '@elastic/eui';
import { UserProfilesSelectable, UserProfilesSelectableProps } from './user_profiles_selectable';
/**
* Props of {@link UserProfilesPopover} component
*/
export interface UserProfilesPopoverProps extends EuiPopoverProps {
/**
* Title of the popover
* @see EuiContextMenuPanelProps
*/
title?: EuiContextMenuPanelProps['title'];
/**
* Props forwarded to selectable component
* @see UserProfilesSelectableProps
*/
selectableProps: UserProfilesSelectableProps;
}
/**
* Renders a selectable component inside a popover given a list of user profiles
*/
export const UserProfilesPopover: FunctionComponent<UserProfilesPopoverProps> = ({
title,
selectableProps,
...popoverProps
}) => {
return (
<EuiPopover panelPaddingSize="none" {...popoverProps}>
<EuiContextMenuPanel title={title}>
<UserProfilesSelectable {...selectableProps} />
</EuiContextMenuPanel>
</EuiPopover>
);
};

View file

@ -0,0 +1,203 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { mount } from 'enzyme';
import React from 'react';
import { UserProfilesSelectable } from './user_profiles_selectable';
const userProfiles = [
{
uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0',
enabled: true,
data: {},
user: {
username: 'delighted_nightingale',
email: 'delighted_nightingale@profiles.elastic.co',
fullName: 'Delighted Nightingale',
},
},
{
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
enabled: true,
data: {},
user: {
username: 'damaged_raccoon',
email: 'damaged_raccoon@profiles.elastic.co',
fullName: 'Damaged Raccoon',
},
},
{
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
enabled: true,
data: {},
user: {
username: 'physical_dinosaur',
email: 'physical_dinosaur@profiles.elastic.co',
fullName: 'Physical Dinosaur',
},
},
{
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
enabled: true,
data: {},
user: {
username: 'wet_dingo',
email: 'wet_dingo@profiles.elastic.co',
fullName: 'Wet Dingo',
},
},
];
describe('UserProfilesSelectable', () => {
it('should render `selectedOptions` before `defaultOptions` separated by a group label', () => {
const [firstOption, secondOption, thirdOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable
selectedOptions={[firstOption]}
defaultOptions={[secondOption, thirdOption]}
/>
);
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: firstOption.uid,
checked: 'on',
}),
expect.objectContaining({
isGroupLabel: true,
label: 'Suggested',
}),
expect.objectContaining({
key: secondOption.uid,
checked: undefined,
}),
expect.objectContaining({
key: thirdOption.uid,
checked: undefined,
}),
]);
});
it('should hide `selectedOptions` and `defaultOptions` when `options` has been provided', () => {
const [firstOption, secondOption, thirdOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable
selectedOptions={[firstOption]}
defaultOptions={[secondOption]}
options={[thirdOption]}
/>
);
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: thirdOption.uid,
checked: undefined,
}),
]);
});
it('should hide `selectedOptions` and `defaultOptions` when `options` gets updated', () => {
const [firstOption, secondOption, thirdOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable selectedOptions={[firstOption]} defaultOptions={[secondOption]} />
);
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: firstOption.uid,
checked: 'on',
}),
expect.objectContaining({
isGroupLabel: true,
label: 'Suggested',
}),
expect.objectContaining({
key: secondOption.uid,
checked: undefined,
}),
]);
wrapper.setProps({ options: [thirdOption] }).update();
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: thirdOption.uid,
checked: undefined,
}),
]);
});
it('should render `options` with correct checked status', () => {
const [firstOption, secondOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable
selectedOptions={[firstOption]}
options={[firstOption, secondOption]}
/>
);
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: firstOption.uid,
checked: 'on',
}),
expect.objectContaining({
key: secondOption.uid,
checked: undefined,
}),
]);
});
it('should trigger `onChange` callback when selection changes', () => {
const onChange = jest.fn();
const [firstOption, secondOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable
selectedOptions={[firstOption]}
defaultOptions={[secondOption]}
onChange={onChange}
/>
);
wrapper.find('EuiSelectableListItem').last().simulate('click');
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
uid: firstOption.uid,
}),
expect.objectContaining({
uid: secondOption.uid,
}),
])
);
});
it('should continue to display `selectedOptions` when getting unchecked', () => {
const onChange = jest.fn();
const [firstOption] = userProfiles;
const wrapper = mount(
<UserProfilesSelectable selectedOptions={[firstOption]} onChange={onChange} />
);
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: firstOption.uid,
checked: 'on',
}),
]);
wrapper.setProps({ selectedOptions: [] }).update();
expect(wrapper.find('EuiSelectable').prop('options')).toEqual([
expect.objectContaining({
key: firstOption.uid,
checked: undefined,
}),
]);
});
it('should trigger `onSearchChange` callback when search term changes', () => {
const onSearchChange = jest.fn();
const wrapper = mount(<UserProfilesSelectable onSearchChange={onSearchChange} />);
wrapper.find('input[type="search"]').simulate('change', { target: { value: 'search' } });
expect(onSearchChange).toHaveBeenCalledWith('search', []);
});
});

View file

@ -0,0 +1,319 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiSelectableOption, EuiSelectableProps } from '@elastic/eui';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
EuiSelectable,
EuiSpacer,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import type { FunctionComponent, ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { getUserDisplayName } from './user_profile';
import type { UserProfileWithAvatar } from './user_avatar';
import { UserAvatar } from './user_avatar';
/**
* Props of {@link UserProfilesSelectable} component
*/
export interface UserProfilesSelectableProps
extends Pick<
EuiSelectableProps,
| 'height'
| 'singleSelection'
| 'loadingMessage'
| 'noMatchesMessage'
| 'emptyMessage'
| 'errorMessage'
> {
/**
* List of users to be rendered as suggestions.
*/
defaultOptions?: UserProfileWithAvatar[];
/**
* List of selected users.
*/
selectedOptions?: UserProfileWithAvatar[];
/**
* List of users from search results. Should be updated based on the search term provided by `onSearchChange` callback.
*/
options?: UserProfileWithAvatar[];
/**
* Passes back the list of selected users.
* @param options List of selected users
*/
onChange?(options: UserProfileWithAvatar[]): void;
/**
* Passes back the search term.
* @param searchTerm Search term
*/
onSearchChange?(searchTerm: string): void;
/**
* Loading indicator for asynchronous search operations.
*/
isLoading?: boolean;
/**
* Placeholder text for search box.
*/
searchPlaceholder?: string;
/**
* Returns text for selected status.
* @param selectedCount Number of selected users
*/
selectedStatusMessage?(selectedCount: number): ReactNode;
/**
* Text for label of clear button.
*/
clearButtonLabel?: ReactNode;
}
/**
* Renders a selectable component given a list of user profiles
*/
export const UserProfilesSelectable: FunctionComponent<UserProfilesSelectableProps> = ({
selectedOptions,
defaultOptions,
options,
onChange,
onSearchChange,
isLoading = false,
singleSelection = false,
height,
loadingMessage,
noMatchesMessage,
emptyMessage,
errorMessage,
searchPlaceholder,
selectedStatusMessage,
clearButtonLabel,
}) => {
const [displayedOptions, setDisplayedOptions] = useState<SelectableOption[]>([]);
// Resets all displayed options
const resetDisplayedOptions = () => {
if (options) {
setDisplayedOptions(options.map(toSelectableOption));
return;
}
setDisplayedOptions([]);
updateDisplayedOptions();
};
const ensureSeparator = (values: SelectableOption[]) => {
let index = values.findIndex((option) => option.isGroupLabel);
if (index === -1) {
const length = values.push({
label: i18n.translate(
'sharedUXPackages.userProfileComponents.userProfilesSelectable.suggestedLabel',
{
defaultMessage: 'Suggested',
}
),
isGroupLabel: true,
} as SelectableOption);
index = length - 1;
}
return index;
};
// Updates displayed options without removing or resorting exiting options
const updateDisplayedOptions = () => {
if (options) {
return;
}
setDisplayedOptions((values) => {
// Copy all displayed options
const nextOptions: SelectableOption[] = [...values];
// Get any newly added selected options
const selectedOptionsToAdd: SelectableOption[] = selectedOptions
? selectedOptions
.filter((profile) => !nextOptions.find((option) => option.key === profile.uid))
.map(toSelectableOption)
: [];
// Get any newly added default options
const defaultOptionsToAdd: SelectableOption[] = defaultOptions
? defaultOptions
.filter(
(profile) =>
!nextOptions.find((option) => option.key === profile.uid) &&
!selectedOptionsToAdd.find((option) => option.key === profile.uid)
)
.map(toSelectableOption)
: [];
// Merge in any new options and add group separator if necessary
if (defaultOptionsToAdd.length) {
const separatorIndex = ensureSeparator(nextOptions);
nextOptions.splice(separatorIndex, 0, ...selectedOptionsToAdd);
nextOptions.push(...defaultOptionsToAdd);
} else {
nextOptions.push(...selectedOptionsToAdd);
}
return nextOptions;
});
};
// Marks displayed options as checked or unchecked depending on `props.selectedOptions`
const updateCheckedStatus = () => {
setDisplayedOptions((values) =>
values.map((option) => {
if (selectedOptions) {
const match = selectedOptions.find((p) => p.uid === option.key);
return { ...option, checked: match ? 'on' : undefined };
}
return { ...option, checked: undefined };
})
);
};
useEffect(resetDisplayedOptions, [options]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(updateDisplayedOptions, [defaultOptions, selectedOptions]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(updateCheckedStatus, [options, defaultOptions, selectedOptions]);
const selectedCount = selectedOptions ? selectedOptions.length : 0;
const placeholder =
searchPlaceholder ??
i18n.translate(
'sharedUXPackages.userProfileComponents.userProfilesSelectable.searchPlaceholder',
{
defaultMessage: 'Search',
}
);
return (
<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 }>>) => {
if (!onChange) {
return;
}
// Take all selected options from `nextOptions` unless already in `props.selectedOptions`
const values: UserProfileWithAvatar[] = nextOptions
.filter((option) => {
if (option.isGroupLabel || option.checked !== 'on') {
return false;
}
if (selectedOptions && selectedOptions.find((p) => p.uid === option.key)) {
return false;
}
return true;
})
.map((option) => 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);
}
});
}
onChange(values);
}}
style={{ maxHeight: height }}
singleSelection={singleSelection}
searchable
searchProps={{
placeholder,
onChange: onSearchChange,
isLoading,
isClearable: !isLoading,
}}
isPreFiltered
listProps={{ onFocusBadge: false }}
loadingMessage={loadingMessage}
noMatchesMessage={noMatchesMessage}
emptyMessage={emptyMessage}
errorMessage={errorMessage}
>
{(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="sharedUXPackages.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="sharedUXPackages.userProfileComponents.userProfilesSelectable.clearButtonLabel"
defaultMessage="Remove all users"
/>
)}
</EuiButtonEmpty>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiHorizontalRule margin="none" />
{list}
</>
)}
</EuiSelectable>
);
};
type SelectableOption = EuiSelectableOption<UserProfileWithAvatar>;
function toSelectableOption(userProfile: UserProfileWithAvatar): SelectableOption {
// @ts-ignore: `isGroupLabel` is not required here but TS complains
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,
};
}

View file

@ -3588,6 +3588,10 @@
version "0.0.0"
uid ""
"@kbn/shared-ux-avatar-user-profile-components@link:bazel-bin/packages/shared-ux/avatar/user_profile/impl":
version "0.0.0"
uid ""
"@kbn/shared-ux-button-exit-full-screen-mocks@link:bazel-bin/packages/shared-ux/button/exit_full_screen/mocks":
version "0.0.0"
uid ""
@ -7605,6 +7609,10 @@
version "0.0.0"
uid ""
"@types/kbn__shared-ux-avatar-user-profile-components@link:bazel-bin/packages/shared-ux/avatar/user_profile/impl/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__shared-ux-button-exit-full-screen-mocks@link:bazel-bin/packages/shared-ux/button/exit_full_screen/mocks/npm_module_types":
version "0.0.0"
uid ""