mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Add user profile selectable (#137424)
* Add reusable user profile selector component * Move to package and add examples * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Add server side example * CI Fixes * fix tests * Addd tests * Addressed suggestions from code review * Fix types * Updated user avatar component * Tweak styling and copy * Add missing jsdoc comments * . * . Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
aa7d8e8ab7
commit
a0731f139e
43 changed files with 1719 additions and 79 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -336,6 +336,7 @@
|
|||
# Kibana Platform Security
|
||||
/packages/kbn-crypto/ @elastic/kibana-security
|
||||
/packages/kbn-handlebars/ @elastic/kibana-security
|
||||
/packages/kbn-user-profile-components/ @elastic/kibana-security
|
||||
/packages/core/http/core-http-server-internal/src/csp/ @elastic/kibana-security @elastic/kibana-core
|
||||
/src/plugins/interactive_setup/ @elastic/kibana-security
|
||||
/src/plugins/telemetry/server/config/telemetry_labels.ts @elastic/kibana-security
|
||||
|
@ -356,6 +357,7 @@
|
|||
/x-pack/test/spaces_api_integration/ @elastic/kibana-security
|
||||
/x-pack/test/saved_object_api_integration/ @elastic/kibana-security
|
||||
/examples/preboot_example/ @elastic/kibana-security @elastic/kibana-core
|
||||
/examples/user_profile_examples/ @elastic/kibana-security
|
||||
#CC# /x-pack/plugins/security/ @elastic/kibana-security
|
||||
|
||||
# Response Ops team
|
||||
|
|
|
@ -77,6 +77,7 @@
|
|||
"uiActionsEnhanced": "src/plugins/ui_actions_enhanced",
|
||||
"uiActionsExamples": "examples/ui_action_examples",
|
||||
"usageCollection": "src/plugins/usage_collection",
|
||||
"userProfileComponents": "packages/kbn-user-profile-components",
|
||||
"utils": "packages/kbn-securitysolution-utils/src",
|
||||
"visDefaultEditor": "src/plugins/vis_default_editor",
|
||||
"visTypeGauge": "src/plugins/vis_types/gauge",
|
||||
|
|
3
examples/user_profile_examples/README.md
Normal file
3
examples/user_profile_examples/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# User profile examples
|
||||
|
||||
Demo of how to implement a suggest user functionality.
|
14
examples/user_profile_examples/kibana.json
Normal file
14
examples/user_profile_examples/kibana.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"id": "userProfileExamples",
|
||||
"kibanaVersion": "kibana",
|
||||
"version": "0.0.1",
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"owner": {
|
||||
"name": "Kibana Platform Security",
|
||||
"githubTeam": "kibana-security"
|
||||
},
|
||||
"description": "Demo of how to implement a suggest user functionality",
|
||||
"requiredPlugins": ["developerExamples", "security", "spaces"],
|
||||
"optionalPlugins": []
|
||||
}
|
66
examples/user_profile_examples/public/avatar_demo.tsx
Normal file
66
examples/user_profile_examples/public/avatar_demo.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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, { FunctionComponent } from 'react';
|
||||
import { EuiTitle, EuiSpacer } from '@elastic/eui';
|
||||
import { UserAvatar } from '@kbn/user-profile-components';
|
||||
import { PanelWithCodeBlock } from './panel_with_code_block';
|
||||
|
||||
export const AvatarDemo: FunctionComponent = () => {
|
||||
const userProfile = {
|
||||
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
data: {
|
||||
avatar: {
|
||||
color: '#09e8ca',
|
||||
initials: 'DN',
|
||||
imageUrl: 'https://source.unsplash.com/64x64/?cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelWithCodeBlock title="Avatar" code={code}>
|
||||
<UserAvatar
|
||||
user={userProfile.user}
|
||||
avatar={{ ...userProfile.data.avatar, imageUrl: undefined }}
|
||||
/>
|
||||
 
|
||||
<UserAvatar user={userProfile.user} avatar={userProfile.data.avatar} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiTitle size="xs">
|
||||
<h3>Unknown</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<UserAvatar />
|
||||
</PanelWithCodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const code = `import { UserAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
const userProfile = {
|
||||
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
data: {
|
||||
avatar: {
|
||||
color: '#09e8ca',
|
||||
initials: 'DN',
|
||||
imageUrl: 'https://source.unsplash.com/64x64/?cat'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
<UserAvatar user={userProfile.user} avatar={userProfile.data.avatar} />`;
|
10
examples/user_profile_examples/public/index.ts
Executable file
10
examples/user_profile_examples/public/index.ts
Executable file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { UserProfilesPlugin } from './plugin';
|
||||
|
||||
export const plugin = () => new UserProfilesPlugin();
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 { EuiTitle, EuiSpacer, EuiSplitPanel, EuiCodeBlock } from '@elastic/eui';
|
||||
|
||||
export interface PanelWithCodeBlockProps {
|
||||
title: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const PanelWithCodeBlock: React.FunctionComponent<PanelWithCodeBlockProps> = ({
|
||||
title,
|
||||
code,
|
||||
children,
|
||||
}) => (
|
||||
<>
|
||||
<EuiTitle>
|
||||
<h2>{title}</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiSplitPanel.Outer hasBorder>
|
||||
<EuiSplitPanel.Inner>{children}</EuiSplitPanel.Inner>
|
||||
<EuiSplitPanel.Inner color="subdued">
|
||||
<EuiCodeBlock language="jsx" paddingSize="none" transparentBackground>
|
||||
{code}
|
||||
</EuiCodeBlock>
|
||||
</EuiSplitPanel.Inner>
|
||||
</EuiSplitPanel.Outer>
|
||||
<EuiSpacer size="l" />
|
||||
</>
|
||||
);
|
68
examples/user_profile_examples/public/plugin.tsx
Executable file
68
examples/user_profile_examples/public/plugin.tsx
Executable file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 ReactDOM from 'react-dom';
|
||||
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
|
||||
import { EuiPageTemplate } from '@elastic/eui';
|
||||
import { AvatarDemo } from './avatar_demo';
|
||||
import { PopoverDemo } from './popover_demo';
|
||||
import { SelectableDemo } from './selectable_demo';
|
||||
|
||||
interface SetupDeps {
|
||||
developerExamples: DeveloperExamplesSetup;
|
||||
security: SecurityPluginSetup;
|
||||
}
|
||||
|
||||
interface StartDeps {
|
||||
security: SecurityPluginStart;
|
||||
}
|
||||
|
||||
export class UserProfilesPlugin implements Plugin<void, void, SetupDeps, StartDeps> {
|
||||
public setup(core: CoreSetup<StartDeps>, deps: SetupDeps) {
|
||||
// Register an application into the side navigation menu
|
||||
core.application.register({
|
||||
id: 'userProfileExamples',
|
||||
title: 'User profile components',
|
||||
async mount({ element }: AppMountParameters) {
|
||||
// Fetch user suggestions
|
||||
// const [, depsStart] = await core.getStartServices();
|
||||
// depsStart.security.userProfiles.suggest('/internal/user_profiles_examples/_suggest', {
|
||||
// name: 'a',
|
||||
// });
|
||||
|
||||
ReactDOM.render(
|
||||
<EuiPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: 'User profile components',
|
||||
}}
|
||||
>
|
||||
<AvatarDemo />
|
||||
<SelectableDemo />
|
||||
<PopoverDemo />
|
||||
</EuiPageTemplate>,
|
||||
element
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
},
|
||||
});
|
||||
|
||||
deps.developerExamples.register({
|
||||
appId: 'userProfileExamples',
|
||||
title: 'User Profile',
|
||||
description: 'Demo of how to implement a suggest user functionality',
|
||||
});
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
106
examples/user_profile_examples/public/popover_demo.tsx
Normal file
106
examples/user_profile_examples/public/popover_demo.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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, { FunctionComponent, useState } from 'react';
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import { UserProfilesPopover, UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import { PanelWithCodeBlock } from './panel_with_code_block';
|
||||
|
||||
export const PopoverDemo: FunctionComponent = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedOptions, setSelectedOptions] = useState<UserProfileWithAvatar[]>([
|
||||
{
|
||||
uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const defaultOptions: UserProfileWithAvatar[] = [
|
||||
{
|
||||
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'damaged_raccoon',
|
||||
email: 'damaged_raccoon@elastic.co',
|
||||
full_name: 'Damaged Raccoon',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'physical_dinosaur',
|
||||
email: 'physical_dinosaur@elastic.co',
|
||||
full_name: 'Physical Dinosaur',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'wet_dingo',
|
||||
email: 'wet_dingo@elastic.co',
|
||||
full_name: 'Wet Dingo',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelWithCodeBlock title="Popover" code={code}>
|
||||
<UserProfilesPopover
|
||||
title="Edit assignees"
|
||||
button={
|
||||
<EuiButtonEmpty iconType="pencil" onClick={() => setIsOpen((value) => !value)}>
|
||||
Edit assignees
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={() => setIsOpen(false)}
|
||||
selectableProps={{
|
||||
selectedOptions,
|
||||
defaultOptions,
|
||||
onChange: setSelectedOptions,
|
||||
height: 32 * 8,
|
||||
}}
|
||||
panelStyle={{
|
||||
width: 32 * 16,
|
||||
}}
|
||||
/>
|
||||
</PanelWithCodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const code = `import { UserProfilesPopover } from '@kbn/user-profile-components';
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState([
|
||||
{
|
||||
uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
<UserProfilesPopover
|
||||
title="Edit assignees"
|
||||
button={
|
||||
<EuiButton>
|
||||
Edit assignees
|
||||
</EuiButton>
|
||||
}
|
||||
selectableProps={{
|
||||
selectedOptions,
|
||||
onChange: setSelectedOptions
|
||||
}}
|
||||
/>`;
|
80
examples/user_profile_examples/public/selectable_demo.tsx
Normal file
80
examples/user_profile_examples/public/selectable_demo.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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, { FunctionComponent, useState } from 'react';
|
||||
import { UserProfilesSelectable, UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import { PanelWithCodeBlock } from './panel_with_code_block';
|
||||
|
||||
export const SelectableDemo: FunctionComponent = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<UserProfileWithAvatar[]>([
|
||||
{
|
||||
uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultOptions: UserProfileWithAvatar[] = [
|
||||
{
|
||||
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'damaged_raccoon',
|
||||
email: 'damaged_raccoon@elastic.co',
|
||||
full_name: 'Damaged Raccoon',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'physical_dinosaur',
|
||||
email: 'physical_dinosaur@elastic.co',
|
||||
full_name: 'Physical Dinosaur',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'wet_dingo',
|
||||
email: 'wet_dingo@elastic.co',
|
||||
full_name: 'Wet Dingo',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelWithCodeBlock title="Selectable" code={code}>
|
||||
<UserProfilesSelectable
|
||||
selectedOptions={selectedOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
onChange={setSelectedOptions}
|
||||
/>
|
||||
</PanelWithCodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const code = `import { UserProfilesSelectable } from '@kbn/user-profile-components';
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState([
|
||||
{
|
||||
uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
<UserProfilesSelectable selectedOptions={selectedOptions} onChange={setSelectedOptions} />`;
|
10
examples/user_profile_examples/server/index.ts
Executable file
10
examples/user_profile_examples/server/index.ts
Executable file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { UserProfilesPlugin } from './plugin';
|
||||
|
||||
export const plugin = () => new UserProfilesPlugin();
|
83
examples/user_profile_examples/server/plugin.ts
Normal file
83
examples/user_profile_examples/server/plugin.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 { Plugin, CoreSetup } from '@kbn/core/server';
|
||||
import {
|
||||
PluginSetupContract as FeaturesPluginSetup,
|
||||
PluginStartContract as FeaturesPluginStart,
|
||||
} from '@kbn/features-plugin/server';
|
||||
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
|
||||
import { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
export interface SetupDeps {
|
||||
features: FeaturesPluginSetup;
|
||||
security: SecurityPluginSetup;
|
||||
spaces: SpacesPluginSetup;
|
||||
}
|
||||
|
||||
export interface StartDeps {
|
||||
features: FeaturesPluginStart;
|
||||
security: SecurityPluginStart;
|
||||
spaces: SpacesPluginStart;
|
||||
}
|
||||
|
||||
export class UserProfilesPlugin implements Plugin<void, void, SetupDeps, StartDeps> {
|
||||
setup(core: CoreSetup<StartDeps>) {
|
||||
const router = core.http.createRouter();
|
||||
router.post(
|
||||
{
|
||||
path: '/internal/user_profiles_examples/_suggest',
|
||||
validate: {
|
||||
body: schema.object({
|
||||
name: schema.string(),
|
||||
dataPath: schema.maybe(schema.string()),
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* Important: You must restrict access to this endpoint using access `tags`.
|
||||
*/
|
||||
options: { tags: ['access:suggestUserProfiles'] },
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const [, pluginDeps] = await core.getStartServices();
|
||||
|
||||
/**
|
||||
* Important: `requiredPrivileges` must be hard-coded server-side and cannot be exposed as a
|
||||
* param client-side.
|
||||
*
|
||||
* If your app requires suggestions based on different privileges you must expose separate
|
||||
* endpoints for each use-case.
|
||||
*
|
||||
* In this example we ensure that suggested users have access to the current space and are
|
||||
* able to login but in your app you will want to change that to something more relevant.
|
||||
*/
|
||||
const profiles = await pluginDeps.security.userProfiles.suggest({
|
||||
name: request.body.name,
|
||||
dataPath: request.body.dataPath,
|
||||
requiredPrivileges: {
|
||||
spaceId: pluginDeps.spaces.spacesService.getSpaceId(request),
|
||||
privileges: {
|
||||
kibana: [pluginDeps.security.authz.actions.login],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok({ body: profiles });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
start() {
|
||||
return {};
|
||||
}
|
||||
|
||||
stop() {
|
||||
return {};
|
||||
}
|
||||
}
|
19
examples/user_profile_examples/tsconfig.json
Normal file
19
examples/user_profile_examples/tsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./target/types"
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"public/**/*.ts",
|
||||
"public/**/*.tsx",
|
||||
"server/**/*.ts",
|
||||
"../../typings/**/*"
|
||||
],
|
||||
"exclude": [],
|
||||
"references": [
|
||||
{ "path": "../../src/core/tsconfig.json" },
|
||||
{ "path": "../../x-pack/plugins/security/tsconfig.json" },
|
||||
{ "path": "../developer_examples/tsconfig.json" }
|
||||
]
|
||||
}
|
|
@ -330,6 +330,7 @@
|
|||
"@kbn/ui-shared-deps-npm": "link:bazel-bin/packages/kbn-ui-shared-deps-npm",
|
||||
"@kbn/ui-shared-deps-src": "link:bazel-bin/packages/kbn-ui-shared-deps-src",
|
||||
"@kbn/ui-theme": "link:bazel-bin/packages/kbn-ui-theme",
|
||||
"@kbn/user-profile-components": "link:bazel-bin/packages/kbn-user-profile-components",
|
||||
"@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types",
|
||||
"@kbn/utility-types-jest": "link:bazel-bin/packages/kbn-utility-types-jest",
|
||||
"@kbn/utils": "link:bazel-bin/packages/kbn-utils",
|
||||
|
@ -993,6 +994,7 @@
|
|||
"@types/kbn__ui-shared-deps-npm": "link:bazel-bin/packages/kbn-ui-shared-deps-npm/npm_module_types",
|
||||
"@types/kbn__ui-shared-deps-src": "link:bazel-bin/packages/kbn-ui-shared-deps-src/npm_module_types",
|
||||
"@types/kbn__ui-theme": "link:bazel-bin/packages/kbn-ui-theme/npm_module_types",
|
||||
"@types/kbn__user-profile-components": "link:bazel-bin/packages/kbn-user-profile-components/npm_module_types",
|
||||
"@types/kbn__utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module_types",
|
||||
"@types/kbn__utility-types-jest": "link:bazel-bin/packages/kbn-utility-types-jest/npm_module_types",
|
||||
"@types/kbn__utils": "link:bazel-bin/packages/kbn-utils/npm_module_types",
|
||||
|
|
|
@ -231,6 +231,7 @@ filegroup(
|
|||
"//packages/kbn-ui-shared-deps-npm:build",
|
||||
"//packages/kbn-ui-shared-deps-src:build",
|
||||
"//packages/kbn-ui-theme:build",
|
||||
"//packages/kbn-user-profile-components:build",
|
||||
"//packages/kbn-utility-types-jest:build",
|
||||
"//packages/kbn-utility-types:build",
|
||||
"//packages/kbn-utils:build",
|
||||
|
@ -475,6 +476,7 @@ filegroup(
|
|||
"//packages/kbn-ui-shared-deps-npm:build_types",
|
||||
"//packages/kbn-ui-shared-deps-src:build_types",
|
||||
"//packages/kbn-ui-theme:build_types",
|
||||
"//packages/kbn-user-profile-components:build_types",
|
||||
"//packages/kbn-utility-types-jest:build_types",
|
||||
"//packages/kbn-utility-types:build_types",
|
||||
"//packages/kbn-utils:build_types",
|
||||
|
|
115
packages/kbn-user-profile-components/BUILD.bazel
Normal file
115
packages/kbn-user-profile-components/BUILD.bazel
Normal file
|
@ -0,0 +1,115 @@
|
|||
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_BASE_NAME = "kbn-user-profile-components"
|
||||
PKG_REQUIRE_NAME = "@kbn/user-profile-components"
|
||||
|
||||
SOURCE_FILES = glob(
|
||||
[
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
SRCS = SOURCE_FILES
|
||||
|
||||
filegroup(
|
||||
name = "srcs",
|
||||
srcs = SRCS,
|
||||
)
|
||||
|
||||
NPM_MODULE_EXTRA_FILES = [
|
||||
"package.json",
|
||||
]
|
||||
|
||||
RUNTIME_DEPS = [
|
||||
"//packages/kbn-i18n",
|
||||
"//packages/kbn-i18n-react",
|
||||
"@npm//@elastic/eui",
|
||||
]
|
||||
|
||||
TYPES_DEPS = [
|
||||
"//packages/kbn-i18n:npm_module_types",
|
||||
"//packages/kbn-i18n-react:npm_module_types",
|
||||
"@npm//@elastic/eui",
|
||||
"@npm//@types/node",
|
||||
"@npm//@types/jest",
|
||||
"@npm//@types/enzyme",
|
||||
"@npm//tslib",
|
||||
]
|
||||
|
||||
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",
|
||||
root_dir = "src",
|
||||
tsconfig = ":tsconfig",
|
||||
)
|
||||
|
||||
js_library(
|
||||
name = PKG_BASE_NAME,
|
||||
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 = [
|
||||
":%s" % PKG_BASE_NAME,
|
||||
]
|
||||
)
|
||||
|
||||
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"],
|
||||
)
|
13
packages/kbn-user-profile-components/jest.config.js
Normal file
13
packages/kbn-user-profile-components/jest.config.js
Normal 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/kbn-user-profile-components'],
|
||||
};
|
9
packages/kbn-user-profile-components/package.json
Normal file
9
packages/kbn-user-profile-components/package.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@kbn/user-profile-components",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./target_node/index.js",
|
||||
"browser": "./target_web/index.js",
|
||||
"types": "./target_types/index.d.ts",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
15
packages/kbn-user-profile-components/src/index.ts
Normal file
15
packages/kbn-user-profile-components/src/index.ts
Normal 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';
|
|
@ -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',
|
||||
full_name: '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',
|
||||
full_name: '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',
|
||||
full_name: '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=""
|
||||
/>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -1,30 +1,59 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 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, HTMLAttributes } from 'react';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { UserProfileAvatarData, UserProfileUserInfoWithSecurity } from '../../common';
|
||||
import type { UserProfile, UserProfileUserInfo, UserProfileAvatarData } from './user_profile';
|
||||
import {
|
||||
getUserAvatarColor,
|
||||
getUserAvatarInitials,
|
||||
getUserDisplayName,
|
||||
USER_AVATAR_MAX_INITIALS,
|
||||
} from '../../common/model';
|
||||
} from './user_profile';
|
||||
|
||||
export interface UserAvatarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'color'> {
|
||||
user?: Pick<UserProfileUserInfoWithSecurity, 'username' | 'full_name'>;
|
||||
/**
|
||||
* 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;
|
||||
size?: EuiAvatarProps['size'];
|
||||
isDisabled?: EuiAvatarProps['isDisabled'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an avatar given a user profile
|
||||
*/
|
||||
export const UserAvatar: FunctionComponent<UserAvatarProps> = ({ user, avatar, ...rest }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
140
packages/kbn-user-profile-components/src/user_profile.ts
Normal file
140
packages/kbn-user-profile-components/src/user_profile.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
full_name?: string;
|
||||
/**
|
||||
* Optional display name of the user.
|
||||
*/
|
||||
display_name?: 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' | 'full_name'>,
|
||||
avatar?: UserProfileAvatarData
|
||||
) {
|
||||
if (avatar && avatar.color) {
|
||||
return avatar.color;
|
||||
}
|
||||
|
||||
const firstCodePoint = getUserDisplayName(user).codePointAt(0) || USER_AVATAR_FALLBACK_CODE_POINT;
|
||||
|
||||
return 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' | 'full_name'>,
|
||||
avatar?: UserProfileAvatarData
|
||||
) {
|
||||
if (avatar && avatar.initials) {
|
||||
return avatar.initials;
|
||||
}
|
||||
|
||||
const words = getUserDisplayName(user).split(' ');
|
||||
const numInitials = Math.min(USER_AVATAR_MAX_INITIALS, words.length);
|
||||
|
||||
words.splice(numInitials, words.length);
|
||||
|
||||
return 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' | 'full_name'>) {
|
||||
return user.full_name || user.username;
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* 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',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@profiles.elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'damaged_raccoon',
|
||||
email: 'damaged_raccoon@profiles.elastic.co',
|
||||
full_name: 'Damaged Raccoon',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'physical_dinosaur',
|
||||
email: 'physical_dinosaur@profiles.elastic.co',
|
||||
full_name: 'Physical Dinosaur',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'wet_dingo',
|
||||
email: 'wet_dingo@profiles.elastic.co',
|
||||
full_name: '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="inlineBlock"
|
||||
hasArrow={true}
|
||||
isOpen={false}
|
||||
ownFocus={true}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
items={Array []}
|
||||
title="Title"
|
||||
>
|
||||
<UserProfilesSelectable
|
||||
defaultOptions={
|
||||
Array [
|
||||
Object {
|
||||
"data": Object {},
|
||||
"uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
|
||||
"user": Object {
|
||||
"email": "damaged_raccoon@profiles.elastic.co",
|
||||
"full_name": "Damaged Raccoon",
|
||||
"username": "damaged_raccoon",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedOptions={
|
||||
Array [
|
||||
Object {
|
||||
"data": Object {},
|
||||
"uid": "u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0",
|
||||
"user": Object {
|
||||
"email": "delighted_nightingale@profiles.elastic.co",
|
||||
"full_name": "Delighted Nightingale",
|
||||
"username": "delighted_nightingale",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</EuiContextMenuPanel>
|
||||
</EuiPopover>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
* 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',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'delighted_nightingale',
|
||||
email: 'delighted_nightingale@profiles.elastic.co',
|
||||
full_name: 'Delighted Nightingale',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'damaged_raccoon',
|
||||
email: 'damaged_raccoon@profiles.elastic.co',
|
||||
full_name: 'Damaged Raccoon',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'physical_dinosaur',
|
||||
email: 'physical_dinosaur@profiles.elastic.co',
|
||||
full_name: 'Physical Dinosaur',
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
|
||||
data: {},
|
||||
user: {
|
||||
username: 'wet_dingo',
|
||||
email: 'wet_dingo@profiles.elastic.co',
|
||||
full_name: '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', []);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,311 @@
|
|||
/*
|
||||
* 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('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;
|
||||
|
||||
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:
|
||||
searchPlaceholder ??
|
||||
i18n.translate('userProfileComponents.userProfilesSelectable.searchPlaceholder', {
|
||||
defaultMessage: 'Search',
|
||||
}),
|
||||
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="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>
|
||||
</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,
|
||||
};
|
||||
}
|
15
packages/kbn-user-profile-components/tsconfig.json
Normal file
15
packages/kbn-user-profile-components/tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "../../tsconfig.bazel.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./target_types",
|
||||
"stripInternal": true,
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
|
@ -47,6 +47,8 @@
|
|||
"@kbn/ui-actions-examples-plugin/*": ["examples/ui_action_examples/*"],
|
||||
"@kbn/ui-actions-explorer-plugin": ["examples/ui_actions_explorer"],
|
||||
"@kbn/ui-actions-explorer-plugin/*": ["examples/ui_actions_explorer/*"],
|
||||
"@kbn/user-profile-examples-plugin": ["examples/user_profile_examples"],
|
||||
"@kbn/user-profile-examples-plugin/*": ["examples/user_profile_examples/*"],
|
||||
"@kbn/advanced-settings-plugin": ["src/plugins/advanced_settings"],
|
||||
"@kbn/advanced-settings-plugin/*": ["src/plugins/advanced_settings/*"],
|
||||
"@kbn/bfetch-plugin": ["src/plugins/bfetch"],
|
||||
|
|
|
@ -10,6 +10,15 @@ import { VISUALIZATION_COLORS } from '@elastic/eui';
|
|||
import type { AuthenticatedUser } from './authenticated_user';
|
||||
import { getUserDisplayName } from './user';
|
||||
|
||||
/**
|
||||
* IMPORTANT:
|
||||
*
|
||||
* The types in this file are duplicated at
|
||||
* `packages/kbn-user-profile-components/src/user_profile.ts`
|
||||
*
|
||||
* When making changes please ensure to keep both files in sync.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Describes basic properties stored in user profile.
|
||||
*/
|
||||
|
@ -19,11 +28,6 @@ export interface UserProfile<D extends UserProfileData = UserProfileData> {
|
|||
*/
|
||||
uid: string;
|
||||
|
||||
/**
|
||||
* Indicates whether user profile is enabled or not.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Information about the user that owns profile.
|
||||
*/
|
||||
|
@ -142,11 +146,11 @@ export const USER_AVATAR_MAX_INITIALS = 2;
|
|||
* 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 {UserProfileUserInfoWithSecurity} user User info
|
||||
* @param {UserProfileUserInfo} user User info
|
||||
* @param {UserProfileAvatarData} avatar User avatar
|
||||
*/
|
||||
export function getUserAvatarColor(
|
||||
user: Pick<UserProfileUserInfoWithSecurity, 'username' | 'full_name'>,
|
||||
user: Pick<UserProfileUserInfo, 'username' | 'full_name'>,
|
||||
avatar?: UserProfileAvatarData
|
||||
) {
|
||||
if (avatar && avatar.color) {
|
||||
|
@ -163,11 +167,11 @@ export function getUserAvatarColor(
|
|||
* 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 {UserProfileUserInfoWithSecurity} user User info
|
||||
* @param {UserProfileUserInfo} user User info
|
||||
* @param {UserProfileAvatarData} avatar User avatar
|
||||
*/
|
||||
export function getUserAvatarInitials(
|
||||
user: Pick<UserProfileUserInfoWithSecurity, 'username' | 'full_name'>,
|
||||
user: Pick<UserProfileUserInfo, 'username' | 'full_name'>,
|
||||
avatar?: UserProfileAvatarData
|
||||
) {
|
||||
if (avatar && avatar.initials) {
|
||||
|
|
|
@ -7,4 +7,8 @@
|
|||
|
||||
export { accountManagementApp } from './account_management_app';
|
||||
export { UserProfileAPIClient } from './user_profile/user_profile_api_client';
|
||||
export type { UserProfileBulkGetParams, UserProfileGetCurrentParams } from './user_profile';
|
||||
export type {
|
||||
UserProfileBulkGetParams,
|
||||
UserProfileGetCurrentParams,
|
||||
UserProfileSuggestParams,
|
||||
} from './user_profile';
|
||||
|
|
|
@ -11,4 +11,5 @@ export type { UserProfileProps, UserProfileFormValues } from './user_profile';
|
|||
export type {
|
||||
UserProfileGetCurrentParams,
|
||||
UserProfileBulkGetParams,
|
||||
UserProfileSuggestParams,
|
||||
} from './user_profile_api_client';
|
||||
|
|
|
@ -15,7 +15,6 @@ import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/
|
|||
import { UserProfileAPIClient } from '..';
|
||||
import type { UserProfileData } from '../../../common';
|
||||
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
|
||||
import { UserAvatar } from '../../components';
|
||||
import { UserAPIClient } from '../../management';
|
||||
import { securityMock } from '../../mocks';
|
||||
import { Providers } from '../account_management_app';
|
||||
|
@ -205,7 +204,7 @@ describe('useUserProfileForm', () => {
|
|||
</Providers>
|
||||
);
|
||||
|
||||
expect(testWrapper.exists(UserAvatar)).toBeTruthy();
|
||||
expect(testWrapper.exists('UserAvatar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not display if the User is a cloud user', () => {
|
||||
|
@ -228,7 +227,7 @@ describe('useUserProfileForm', () => {
|
|||
</Providers>
|
||||
);
|
||||
|
||||
expect(testWrapper.exists(UserAvatar)).toBeFalsy();
|
||||
expect(testWrapper.exists('UserAvatar')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -33,6 +33,7 @@ import type { CoreStart } from '@kbn/core/public';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { UserAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import type { AuthenticatedUser, UserProfileAvatarData } from '../../../common';
|
||||
import {
|
||||
|
@ -41,7 +42,7 @@ import {
|
|||
getUserAvatarColor,
|
||||
getUserAvatarInitials,
|
||||
} from '../../../common/model';
|
||||
import { UserAvatar, useSecurityApiClients } from '../../components';
|
||||
import { useSecurityApiClients } from '../../components';
|
||||
import { Breadcrumb } from '../../components/breadcrumb';
|
||||
import {
|
||||
FormChangesProvider,
|
||||
|
|
|
@ -39,6 +39,28 @@ export interface UserProfileBulkGetParams {
|
|||
dataPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the suggest API.
|
||||
*/
|
||||
export interface UserProfileSuggestParams {
|
||||
/**
|
||||
* Query string used to match name-related fields in user profiles. The following fields are treated as
|
||||
* name-related: username, full_name and email.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Desired number of suggestions to return. The default value is 10.
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
/**
|
||||
* By default, suggest API returns user information, but does not return any user data. The optional "dataPath"
|
||||
* parameter can be used to return personal data for this user (within `kibana` namespace only).
|
||||
*/
|
||||
dataPath?: string;
|
||||
}
|
||||
|
||||
export class UserProfileAPIClient {
|
||||
private readonly internalDataUpdates$: Subject<UserProfileData> = new Subject();
|
||||
|
||||
|
@ -76,6 +98,28 @@ export class UserProfileAPIClient {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests multiple user profiles by search criteria.
|
||||
*
|
||||
* Note: This endpoint is not provided out-of-the-box by the platform. You need to expose your own
|
||||
* version within your app. An example of how to do this can be found in:
|
||||
* `examples/user_profile_examples/server/plugin.ts`
|
||||
*
|
||||
* @param path Path to your app's suggest endpoint.
|
||||
* @param params Suggest operation parameters.
|
||||
* @param params.name Query string used to match name-related fields in user profiles. The
|
||||
* following fields are treated as name-related: username, full_name and email.
|
||||
* @param params.size Desired number of suggestions to return. The default value is 10.
|
||||
* @param params.dataPath By default, suggest API returns user information, but does not return
|
||||
* any user data. The optional "dataPath" parameter can be used to return personal data for this
|
||||
* user (within `kibana` namespace only).
|
||||
*/
|
||||
public suggest<D extends UserProfileData>(path: string, params: UserProfileSuggestParams) {
|
||||
return this.http.post<Array<UserProfile<D>>>(path, {
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile data of the current user.
|
||||
* @param data Application data to be written (merged with existing data).
|
||||
|
|
|
@ -13,5 +13,3 @@ export {
|
|||
useUserProfile,
|
||||
useCurrentUser,
|
||||
} from './use_current_user';
|
||||
export { UserAvatar } from './user_avatar';
|
||||
export type { UserAvatarProps } from './user_avatar';
|
||||
|
|
|
@ -20,7 +20,11 @@ export type { AuthenticatedUser } from '../common/model';
|
|||
export type { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing';
|
||||
export type { UiApi, ChangePasswordProps, PersonalInfoProps } from './ui_api';
|
||||
export type { UserMenuLink, SecurityNavControlServiceStart } from './nav_control';
|
||||
export type { UserProfileBulkGetParams, UserProfileGetCurrentParams } from './account_management';
|
||||
export type {
|
||||
UserProfileBulkGetParams,
|
||||
UserProfileGetCurrentParams,
|
||||
UserProfileSuggestParams,
|
||||
} from './account_management';
|
||||
|
||||
export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication';
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ function createStartMock() {
|
|||
return {
|
||||
authc: authenticationMock.createStart(),
|
||||
navControlService: navControlServiceMock.createStart(),
|
||||
userProfiles: { getCurrent: jest.fn(), bulkGet: jest.fn() },
|
||||
userProfiles: { getCurrent: jest.fn(), bulkGet: jest.fn(), suggest: jest.fn() },
|
||||
uiApi: getUiApiMock.createStart(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -85,26 +85,11 @@ describe('SecurityNavControl', () => {
|
|||
"name": "basic1",
|
||||
"type": "basic",
|
||||
},
|
||||
"authentication_realm": Object {
|
||||
"name": "native1",
|
||||
"type": "native",
|
||||
},
|
||||
"authentication_type": "realm",
|
||||
"elastic_cloud_user": false,
|
||||
"email": "email",
|
||||
"enabled": true,
|
||||
"full_name": "full name",
|
||||
"lookup_realm": Object {
|
||||
"name": "native1",
|
||||
"type": "native",
|
||||
},
|
||||
"metadata": Object {
|
||||
"_reserved": false,
|
||||
},
|
||||
"roles": Array [
|
||||
"user-role",
|
||||
],
|
||||
"username": "user",
|
||||
"email": "some@email",
|
||||
"realm_domain": "some-realm-domain",
|
||||
"realm_name": "some-realm",
|
||||
"roles": Array [],
|
||||
"username": "some-username",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -20,10 +20,11 @@ import type { Observable } from 'rxjs';
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { UserAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import type { UserProfileAvatarData } from '../../common';
|
||||
import { getUserDisplayName, isUserAnonymous } from '../../common/model';
|
||||
import { useCurrentUser, UserAvatar, useUserProfile } from '../components';
|
||||
import { useCurrentUser, useUserProfile } from '../components';
|
||||
|
||||
export interface UserMenuLink {
|
||||
label: string;
|
||||
|
@ -64,9 +65,9 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({
|
|||
data-test-subj="userMenuButton"
|
||||
style={{ lineHeight: 'normal' }}
|
||||
>
|
||||
{currentUser.value && userProfile.value ? (
|
||||
{userProfile.value ? (
|
||||
<UserAvatar
|
||||
user={currentUser.value}
|
||||
user={userProfile.value.user}
|
||||
avatar={userProfile.value.data.avatar}
|
||||
size="s"
|
||||
data-test-subj="userMenuAvatar"
|
||||
|
|
|
@ -109,6 +109,7 @@ describe('Security Plugin', () => {
|
|||
"userProfiles": Object {
|
||||
"bulkGet": [Function],
|
||||
"getCurrent": [Function],
|
||||
"suggest": [Function],
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
|
|
@ -21,10 +21,8 @@ import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/pu
|
|||
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
|
||||
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
|
||||
|
||||
import type { UserProfile, UserProfileData, UserProfileWithSecurity } from '../common';
|
||||
import type { SecurityLicense } from '../common/licensing';
|
||||
import { SecurityLicenseService } from '../common/licensing';
|
||||
import type { UserProfileBulkGetParams, UserProfileGetCurrentParams } from './account_management';
|
||||
import { accountManagementApp, UserProfileAPIClient } from './account_management';
|
||||
import { AnalyticsService } from './analytics';
|
||||
import { AnonymousAccessService } from './anonymous_access';
|
||||
|
@ -192,6 +190,9 @@ export class SecurityPlugin
|
|||
bulkGet: this.securityApiClients.userProfiles.bulkGet.bind(
|
||||
this.securityApiClients.userProfiles
|
||||
),
|
||||
suggest: this.securityApiClients.userProfiles.suggest.bind(
|
||||
this.securityApiClients.userProfiles
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -232,28 +233,7 @@ export interface SecurityPluginStart {
|
|||
/**
|
||||
* A set of methods to work with Kibana user profiles.
|
||||
*/
|
||||
userProfiles: {
|
||||
/**
|
||||
* Retrieves the user profile of the current user. If the profile isn't available, e.g. for the anonymous users or
|
||||
* users authenticated via authenticating proxies, the `null` value is returned.
|
||||
* @param [params] Get current user profile operation parameters.
|
||||
* @param params.dataPath By default `getCurrent()` returns user information, but does not return any user data. The
|
||||
* optional "dataPath" parameter can be used to return personal data for this user.
|
||||
*/
|
||||
getCurrent<D extends UserProfileData>(
|
||||
params?: UserProfileGetCurrentParams
|
||||
): Promise<UserProfileWithSecurity<D> | null>;
|
||||
/**
|
||||
* Retrieves multiple user profiles by their identifiers.
|
||||
* @param params Bulk get operation parameters.
|
||||
* @param params.uids List of user profile identifiers.
|
||||
* @param params.dataPath By default Elasticsearch returns user information, but does not return any user data. The
|
||||
* optional "dataPath" parameter can be used to return personal data for the requested user profiles.
|
||||
*/
|
||||
bulkGet<D extends UserProfileData>(
|
||||
params: UserProfileBulkGetParams
|
||||
): Promise<Array<UserProfile<D>>>;
|
||||
};
|
||||
userProfiles: Pick<UserProfileAPIClient, 'getCurrent' | 'bulkGet' | 'suggest'>;
|
||||
|
||||
/**
|
||||
* Exposes UI components that will be loaded asynchronously.
|
||||
|
|
|
@ -62,12 +62,12 @@ export interface UserProfileServiceStart {
|
|||
): Promise<Array<UserProfile<D>>>;
|
||||
|
||||
/**
|
||||
* Retrieves a single user profile by identifier.
|
||||
* Suggests multiple user profiles by search criteria.
|
||||
* @param params Suggest operation parameters.
|
||||
* @param params.name Query string used to match name-related fields in user profiles. The following fields are
|
||||
* treated as name-related: username, full_name and email.
|
||||
* @param params.dataPath By default API returns user information, but does not return any user data. The optional
|
||||
* "dataPath" parameter can be used to return personal data for this user (within `kibana` namespace).
|
||||
* @param params.name Query string used to match name-related fields in user profiles. The following fields are treated as name-related: username, full_name and email.
|
||||
* @param params.size Desired number of suggestion to return. The default value is 10.
|
||||
* @param params.dataPath By default, suggest API returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user (within `kibana` namespace only).
|
||||
* @param params.requiredPrivileges The set of the privileges that users associated with the suggested user profile should have in the specified space. If not specified, privileges check isn't performed and all matched profiles are returned irrespective to the privileges of the associated users.
|
||||
*/
|
||||
suggest<D extends UserProfileData>(
|
||||
params: UserProfileSuggestParams
|
||||
|
|
|
@ -3911,6 +3911,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/user-profile-components@link:bazel-bin/packages/kbn-user-profile-components":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/utility-types-jest@link:bazel-bin/packages/kbn-utility-types-jest":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
@ -7771,6 +7775,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@types/kbn__user-profile-components@link:bazel-bin/packages/kbn-user-profile-components/npm_module_types":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@types/kbn__utility-types-jest@link:bazel-bin/packages/kbn-utility-types-jest/npm_module_types":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue