diff --git a/package.json b/package.json index 4dd8f51125c26..7eec23fc30a4d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared-ux/avatar/user_profile/impl/BUILD.bazel b/packages/shared-ux/avatar/user_profile/impl/BUILD.bazel new file mode 100644 index 0000000000000..447bd41d39788 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/BUILD.bazel @@ -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"], +) diff --git a/packages/shared-ux/avatar/user_profile/impl/README.mdx b/packages/shared-ux/avatar/user_profile/impl/README.mdx new file mode 100644 index 0000000000000..1522cec340b9b --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/README.mdx @@ -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 diff --git a/packages/shared-ux/avatar/user_profile/impl/index.ts b/packages/shared-ux/avatar/user_profile/impl/index.ts new file mode 100644 index 0000000000000..e36215e36896a --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/index.ts @@ -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'; diff --git a/packages/shared-ux/avatar/user_profile/impl/jest.config.js b/packages/shared-ux/avatar/user_profile/impl/jest.config.js new file mode 100644 index 0000000000000..111a2a8105057 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/jest.config.js @@ -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: ['/packages/shared-ux/avatar/user_profile/impl'], +}; diff --git a/packages/shared-ux/avatar/user_profile/impl/package.json b/packages/shared-ux/avatar/user_profile/impl/package.json new file mode 100644 index 0000000000000..4621591d690cb --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/package.json @@ -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" +} diff --git a/packages/shared-ux/avatar/user_profile/impl/tsconfig.json b/packages/shared-ux/avatar/user_profile/impl/tsconfig.json new file mode 100644 index 0000000000000..5f12c69172930 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/tsconfig.json @@ -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*", + ] +} diff --git a/packages/shared-ux/avatar/user_profile/impl/user_avatar.test.tsx b/packages/shared-ux/avatar/user_profile/impl/user_avatar.test.tsx new file mode 100644 index 0000000000000..6a62d14c75642 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_avatar.test.tsx @@ -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( + + ); + expect(wrapper).toMatchInlineSnapshot(` + + `); + }); + + it('should render `EuiAvatar` correctly with initials avatar', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchInlineSnapshot(` + + `); + }); + + it('should render `EuiAvatar` correctly without avatar data', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchInlineSnapshot(` + + `); + }); + + it('should render `EuiAvatar` correctly without user data', () => { + const wrapper = shallow(); + expect(wrapper).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/packages/shared-ux/avatar/user_profile/impl/user_avatar.tsx b/packages/shared-ux/avatar/user_profile/impl/user_avatar.tsx new file mode 100644 index 0000000000000..2413694317c27 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_avatar.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 = ({ user, avatar, ...rest }) => { + const { euiTheme } = useEuiTheme(); + + if (!user) { + return ; + } + + const displayName = getUserDisplayName(user); + + if (avatar?.imageUrl) { + return ; + } + + return ( + + ); +}; diff --git a/packages/shared-ux/avatar/user_profile/impl/user_profile.ts b/packages/shared-ux/avatar/user_profile/impl/user_profile.ts new file mode 100644 index 0000000000000..f82816df3d3e3 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_profile.ts @@ -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 { + /** + * 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; +} + +/** + * 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; + +/** + * 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, + 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, + 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) { + return user.fullName || user.username; +} diff --git a/packages/shared-ux/avatar/user_profile/impl/user_profiles.stories.tsx b/packages/shared-ux/avatar/user_profile/impl/user_profiles.stories.tsx new file mode 100644 index 0000000000000..e7a7fa719f2e8 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_profiles.stories.tsx @@ -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; +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, + rest: UserAvatarParams +) => { + const username = params; + return ; +}; + +userAvatar.argTypes = { + username: { + control: { type: 'radio' }, + options: sampleUsers.map(({ username }) => username), + defaultValue: sampleUsers.map(({ username }) => username)[0], + }, +}; diff --git a/packages/shared-ux/avatar/user_profile/impl/user_profiles_popover.test.tsx b/packages/shared-ux/avatar/user_profile/impl/user_profiles_popover.test.tsx new file mode 100644 index 0000000000000..ecdd455adedd4 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_profiles_popover.test.tsx @@ -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( + Toggle} + closePopover={jest.fn()} + selectableProps={{ + selectedOptions: [firstOption], + defaultOptions: [secondOption], + }} + /> + ); + expect(wrapper).toMatchInlineSnapshot(` + + Toggle + + } + closePopover={[MockFunction]} + display="inline-block" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + `); + }); +}); diff --git a/packages/shared-ux/avatar/user_profile/impl/user_profiles_popover.tsx b/packages/shared-ux/avatar/user_profile/impl/user_profiles_popover.tsx new file mode 100644 index 0000000000000..9fc553d9be689 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_profiles_popover.tsx @@ -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 = ({ + title, + selectableProps, + ...popoverProps +}) => { + return ( + + + + + + ); +}; diff --git a/packages/shared-ux/avatar/user_profile/impl/user_profiles_selectable.test.tsx b/packages/shared-ux/avatar/user_profile/impl/user_profiles_selectable.test.tsx new file mode 100644 index 0000000000000..d17e70c566f43 --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_profiles_selectable.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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(); + wrapper.find('input[type="search"]').simulate('change', { target: { value: 'search' } }); + expect(onSearchChange).toHaveBeenCalledWith('search', []); + }); +}); diff --git a/packages/shared-ux/avatar/user_profile/impl/user_profiles_selectable.tsx b/packages/shared-ux/avatar/user_profile/impl/user_profiles_selectable.tsx new file mode 100644 index 0000000000000..ea0e2260f44fd --- /dev/null +++ b/packages/shared-ux/avatar/user_profile/impl/user_profiles_selectable.tsx @@ -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 = ({ + selectedOptions, + defaultOptions, + options, + onChange, + onSearchChange, + isLoading = false, + singleSelection = false, + height, + loadingMessage, + noMatchesMessage, + emptyMessage, + errorMessage, + searchPlaceholder, + selectedStatusMessage, + clearButtonLabel, +}) => { + const [displayedOptions, setDisplayedOptions] = useState([]); + + // 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 ( + >) => { + 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) => ( + <> + + {search} + + + + + {selectedStatusMessage ? ( + selectedStatusMessage(selectedCount) + ) : ( + + )} + + + + {selectedCount ? ( + onChange?.([])} + style={{ height: '1rem' }} + > + {clearButtonLabel ?? ( + + )} + + ) : null} + + + + + {list} + + )} + + ); +}; + +type SelectableOption = EuiSelectableOption; + +function toSelectableOption(userProfile: UserProfileWithAvatar): SelectableOption { + // @ts-ignore: `isGroupLabel` is not required here but TS complains + return { + key: userProfile.uid, + prepend: , + label: getUserDisplayName(userProfile.user), + append: {userProfile.user.email}, + data: userProfile, + }; +} diff --git a/yarn.lock b/yarn.lock index 747e7c6943aa2..bd2afe841fbfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""