feat(ui): revamp user profile page (#13744)

* revamp user profile page

* minor fixes

* unit test fixes

* added unit test for user components

* added unit test for user-profile roles and minor improvement

* supported unit test for inheritet roles

* added unit test for user-profile-image  and minor improvement

* minor fixes

* cypress fixes

* fix the user icon and name in case of no display name

* changes as per comments
This commit is contained in:
Ashish Gupta 2023-11-01 11:30:55 +05:30 committed by GitHub
parent d1898ffbbc
commit 962c3d6591
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1039 additions and 292 deletions

View File

@ -127,6 +127,14 @@ describe('DataConsumer Edit policy should work properly', () => {
CREDENTIALS.lastName CREDENTIALS.lastName
).then((id) => { ).then((id) => {
CREDENTIALS.id = id; CREDENTIALS.id = id;
cy.clickOutside();
// click the collapse button to open the other details
cy.get(
'[data-testid="user-profile"] .ant-collapse-expand-icon > .anticon > svg'
).click();
cy.get( cy.get(
'[data-testid="user-profile"] [data-testid="user-profile-inherited-roles"]' '[data-testid="user-profile"] [data-testid="user-profile-inherited-roles"]'
).should('contain', policy); ).should('contain', policy);

View File

@ -107,6 +107,11 @@ describe('Test Add role and assign it to the user', () => {
cy.get(`[data-testid="${userName}"]`).click(); cy.get(`[data-testid="${userName}"]`).click();
verifyResponseStatusCode('@userDetailsPage', 200); verifyResponseStatusCode('@userDetailsPage', 200);
// click the collapse button to open the other details
cy.get(
'[data-testid="user-profile"] .ant-collapse-expand-icon > .anticon > svg'
).click();
cy.get( cy.get(
'[data-testid="user-profile"] [data-testid="user-profile-roles"]' '[data-testid="user-profile"] [data-testid="user-profile-roles"]'
).should('contain', roleName); ).should('contain', roleName);

View File

@ -1034,10 +1034,10 @@ const TeamDetailsV1 = ({
<Collapse <Collapse
accordion accordion
bordered={false} bordered={false}
className="site-collapse-custom-collapse" className="header-collapse-custom-collapse"
expandIconPosition="end"> expandIconPosition="end">
<Collapse.Panel <Collapse.Panel
className="site-collapse-custom-panel" className="header-collapse-custom-panel"
collapsible="icon" collapsible="icon"
header={teamsCollapseHeader} header={teamsCollapseHeader}
key="1"> key="1">

View File

@ -113,17 +113,6 @@
overflow-y: scroll; overflow-y: scroll;
} }
} }
.site-collapse-custom-collapse .site-collapse-custom-panel {
overflow: hidden;
padding: 0;
background: @user-profile-background;
border: 0px;
.ant-collapse-content-box {
padding: 0;
}
}
} }
.team-assets-right-panel { .team-assets-right-panel {
.summary-panel-container { .summary-panel-container {

View File

@ -110,7 +110,7 @@ export const UserProfileIcon = () => {
}, [profilePicture]); }, [profilePicture]);
const { userName, teams, roles, inheritedRoles, personas } = useMemo(() => { const { userName, teams, roles, inheritedRoles, personas } = useMemo(() => {
const userName = currentUser?.displayName ?? currentUser?.name ?? TERM_USER; const userName = getEntityName(currentUser) || TERM_USER;
return { return {
userName, userName,

View File

@ -12,11 +12,13 @@
*/ */
import { import {
findByRole,
findByTestId, findByTestId,
findByText, findByText,
queryByTestId, queryByTestId,
render, render,
} from '@testing-library/react'; } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { mockTeamsData, mockUserData, mockUserRole } from './mocks/User.mocks'; import { mockTeamsData, mockUserData, mockUserRole } from './mocks/User.mocks';
@ -174,7 +176,22 @@ describe('Test User Component', () => {
container, container,
'UserProfileDetails' 'UserProfileDetails'
); );
const UserProfileImage = await findByText(container, 'UserProfileImage');
expect(UserProfileDetails).toBeInTheDocument();
});
it('User profile should render when open collapsible header', async () => {
const { container } = render(
<Users userData={mockUserData} {...mockProp} />,
{
wrapper: MemoryRouter,
}
);
const collapsibleButton = await findByRole(container, 'img');
userEvent.click(collapsibleButton);
const UserProfileInheritedRoles = await findByText( const UserProfileInheritedRoles = await findByText(
container, container,
'UserProfileInheritedRoles' 'UserProfileInheritedRoles'
@ -183,8 +200,6 @@ describe('Test User Component', () => {
const UserProfileTeams = await findByText(container, 'UserProfileTeams'); const UserProfileTeams = await findByText(container, 'UserProfileTeams');
expect(UserProfileDetails).toBeInTheDocument();
expect(UserProfileImage).toBeInTheDocument();
expect(UserProfileRoles).toBeInTheDocument(); expect(UserProfileRoles).toBeInTheDocument();
expect(UserProfileTeams).toBeInTheDocument(); expect(UserProfileTeams).toBeInTheDocument();
expect(UserProfileInheritedRoles).toBeInTheDocument(); expect(UserProfileInheritedRoles).toBeInTheDocument();
@ -212,7 +227,7 @@ describe('Test User Component', () => {
} }
); );
const datasetContainer = await findByTestId(container, 'table-container'); const datasetContainer = await findByTestId(container, 'user-profile');
expect(datasetContainer).toBeInTheDocument(); expect(datasetContainer).toBeInTheDocument();
}); });

View File

@ -11,9 +11,9 @@
* limitations under the License. * limitations under the License.
*/ */
import { Col, Row, Tabs, Typography } from 'antd'; import { Col, Collapse, Row, Space, Tabs, Typography } from 'antd';
import Card from 'antd/lib/card/Card'; import Card from 'antd/lib/card/Card';
import { noop } from 'lodash'; import { isEmpty, noop } from 'lodash';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -22,19 +22,17 @@ import { ReactComponent as PersonaIcon } from '../../assets/svg/ic-personas.svg'
import ActivityFeedProvider from '../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider'; import ActivityFeedProvider from '../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import { ActivityFeedTab } from '../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component'; import { ActivityFeedTab } from '../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component';
import TabsLabel from '../../components/TabsLabel/TabsLabel.component'; import TabsLabel from '../../components/TabsLabel/TabsLabel.component';
import { import { getUserPath, ROUTES } from '../../constants/constants';
getUserPath,
NO_DATA_PLACEHOLDER,
ROUTES,
} from '../../constants/constants';
import { myDataSearchIndex } from '../../constants/Mydata.constants'; import { myDataSearchIndex } from '../../constants/Mydata.constants';
import { EntityType } from '../../enums/entity.enum'; import { EntityType } from '../../enums/entity.enum';
import { EntityReference } from '../../generated/entity/type'; import { EntityReference } from '../../generated/entity/type';
import { useAuth } from '../../hooks/authHooks'; import { useAuth } from '../../hooks/authHooks';
import { searchData } from '../../rest/miscAPI'; import { searchData } from '../../rest/miscAPI';
import { getEntityName } from '../../utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import { useAuthContext } from '../authentication/auth-provider/AuthProvider'; import { useAuthContext } from '../authentication/auth-provider/AuthProvider';
import Chip from '../common/Chip/Chip.component'; import Chip from '../common/Chip/Chip.component';
import DescriptionV1 from '../common/description/DescriptionV1';
import PageLayoutV1 from '../containers/PageLayoutV1'; import PageLayoutV1 from '../containers/PageLayoutV1';
import EntitySummaryPanel from '../Explore/EntitySummaryPanel/EntitySummaryPanel.component'; import EntitySummaryPanel from '../Explore/EntitySummaryPanel/EntitySummaryPanel.component';
import { EntityDetailsObjectInterface } from '../Explore/explore.interface'; import { EntityDetailsObjectInterface } from '../Explore/explore.interface';
@ -47,7 +45,6 @@ import { PersonaSelectableList } from '../Persona/PersonaSelectableList/PersonaS
import { Props, UserPageTabs } from './Users.interface'; import { Props, UserPageTabs } from './Users.interface';
import './Users.style.less'; import './Users.style.less';
import UserProfileDetails from './UsersProfile/UserProfileDetails/UserProfileDetails.component'; import UserProfileDetails from './UsersProfile/UserProfileDetails/UserProfileDetails.component';
import UserProfileImage from './UsersProfile/UserProfileImage/UserProfileImage.component';
import UserProfileInheritedRoles from './UsersProfile/UserProfileInheritedRoles/UserProfileInheritedRoles.component'; import UserProfileInheritedRoles from './UsersProfile/UserProfileInheritedRoles/UserProfileInheritedRoles.component';
import UserProfileRoles from './UsersProfile/UserProfileRoles/UserProfileRoles.component'; import UserProfileRoles from './UsersProfile/UserProfileRoles/UserProfileRoles.component';
import UserProfileTeams from './UsersProfile/UserProfileTeams/UserProfileTeams.component'; import UserProfileTeams from './UsersProfile/UserProfileTeams/UserProfileTeams.component';
@ -66,18 +63,22 @@ const Users = ({
const location = useLocation(); const location = useLocation();
const { currentUser } = useAuthContext(); const { currentUser } = useAuthContext();
const isSelfProfileView = userData?.id === currentUser?.id;
const [previewAsset, setPreviewAsset] = const [previewAsset, setPreviewAsset] =
useState<EntityDetailsObjectInterface>(); useState<EntityDetailsObjectInterface>();
const [isDescriptionEdit, setIsDescriptionEdit] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const defaultPersona = useMemo(() => { const isLoggedInUser = useMemo(
return userData.personas?.find( () => username === currentUser?.name,
(persona) => persona.id === userData.defaultPersona?.id [username]
); );
}, [userData]);
const hasEditPermission = useMemo(
() => isAdminUser || isLoggedInUser,
[isAdminUser, isLoggedInUser]
);
const fetchAssetsCount = async (query: string) => { const fetchAssetsCount = async (query: string) => {
try { try {
@ -111,13 +112,6 @@ const Users = ({
[updateUserDetails, userData] [updateUserDetails, userData]
); );
const handleDefaultPersonaUpdate = useCallback(
async (defaultPersona?: EntityReference) => {
await updateUserDetails({ ...userData, defaultPersona });
},
[updateUserDetails, userData]
);
const tabDataRender = useCallback( const tabDataRender = useCallback(
(props: { (props: {
queryFilter: string; queryFilter: string;
@ -212,6 +206,63 @@ const Users = ({
[activeTab, userData, username, setPreviewAsset, tabDataRender] [activeTab, userData, username, setPreviewAsset, tabDataRender]
); );
const handleDescriptionChange = useCallback(
async (description: string) => {
await updateUserDetails({ description });
setIsDescriptionEdit(false);
},
[updateUserDetails, setIsDescriptionEdit]
);
const descriptionRenderComponent = useMemo(
() =>
hasEditPermission ? (
<DescriptionV1
description={userData.description ?? ''}
entityName={getEntityName(userData as unknown as EntityReference)}
entityType={EntityType.USER}
hasEditAccess={hasEditPermission}
isEdit={isDescriptionEdit}
showCommentsIcon={false}
onCancel={() => setIsDescriptionEdit(false)}
onDescriptionEdit={() => setIsDescriptionEdit(true)}
onDescriptionUpdate={handleDescriptionChange}
/>
) : (
<Space direction="vertical" size="middle">
<Typography.Text className="right-panel-label">
{t('label.description')}
</Typography.Text>
<Typography.Paragraph className="m-b-0">
{isEmpty(userData.description)
? t('label.no-entity', {
entity: t('label.description'),
})
: userData.description}
</Typography.Paragraph>
</Space>
),
[
userData,
isAdminUser,
isDescriptionEdit,
hasEditPermission,
getEntityName,
handleDescriptionChange,
]
);
const userProfileCollapseHeader = useMemo(
() => (
<UserProfileDetails
updateUserDetails={updateUserDetails}
userData={userData}
/>
),
[userData, updateUserDetails]
);
useEffect(() => { useEffect(() => {
if ([UserPageTabs.MY_DATA, UserPageTabs.FOLLOWING].includes(activeTab)) { if ([UserPageTabs.MY_DATA, UserPageTabs.FOLLOWING].includes(activeTab)) {
fetchAssetsCount( fetchAssetsCount(
@ -224,97 +275,72 @@ const Users = ({
return ( return (
<PageLayoutV1 className="user-layout h-full" pageTitle={t('label.user')}> <PageLayoutV1 className="user-layout h-full" pageTitle={t('label.user')}>
<div data-testid="table-container"> <div data-testid="user-profile">
<Row className="user-profile-container" data-testid="user-profile"> <Collapse
<Col className="flex-center border-right" span={4}> accordion
<UserProfileImage bordered={false}
userData={{ className="header-collapse-custom-collapse user-profile-container"
id: userData.id, expandIconPosition="end">
name: userData.name, <Collapse.Panel
displayName: userData.displayName, className="header-collapse-custom-panel"
images: userData.profile?.images, collapsible="icon"
}} header={userProfileCollapseHeader}
/> key="1">
</Col> <Row className="border-top p-y-lg" gutter={[0, 24]}>
<Col className="p-x-sm border-right" span={5}> <Col span={24}>
<UserProfileDetails <Row data-testid="user-profile-details">
updateUserDetails={updateUserDetails} <Col className="p-x-sm border-right" span={6}>
userData={{ <UserProfileTeams
email: userData.email, teams={userData.teams}
name: userData.name, updateUserDetails={updateUserDetails}
displayName: userData.displayName,
description: userData.description,
}}
/>
</Col>
<Col className="p-x-sm border-right" span={5}>
<UserProfileTeams
teams={userData.teams}
updateUserDetails={updateUserDetails}
/>
</Col>
<Col className="p-x-sm border-right" span={5}>
<div className="d-flex flex-col justify-between h-full">
<UserProfileRoles
isUserAdmin={userData.isAdmin}
updateUserDetails={updateUserDetails}
userRoles={userData.roles}
/>
<UserProfileInheritedRoles
inheritedRoles={userData.inheritedRoles}
/>
</div>
</Col>
<Col className="p-x-sm border-right" span={5}>
<div className="d-flex flex-col justify-between h-full">
<Card
className="ant-card-feed relative card-body-border-none card-padding-y-0 m-b-md"
title={
<Typography.Text
className="right-panel-label items-center d-flex gap-2"
data-testid="inherited-roles">
{t('label.persona')}
<PersonaSelectableList
multiSelect
hasPermission={Boolean(isAdminUser)}
selectedPersonas={userData.personas ?? []}
onUpdate={handlePersonaUpdate}
/> />
</Typography.Text> </Col>
}> <Col className="p-x-sm border-right" span={6}>
<Chip <UserProfileRoles
showNoDataPlaceholder updateUserDetails={updateUserDetails}
data={userData.personas ?? []} userRoles={userData.roles}
icon={<PersonaIcon height={18} />}
noDataPlaceholder={t('message.no-persona-assigned')}
/>
</Card>
<Card
className="ant-card-feed relative card-body-border-none card-padding-y-0"
title={
<Typography.Text
className="right-panel-label m-b-0 d-flex gap-2"
data-testid="inherited-roles">
{t('label.default-persona')}
<PersonaSelectableList
hasPermission={isAdminUser || isSelfProfileView}
multiSelect={false}
personaList={userData.personas}
selectedPersonas={defaultPersona ? [defaultPersona] : []}
onUpdate={handleDefaultPersonaUpdate}
/> />
</Typography.Text> </Col>
}> <Col className="p-x-sm border-right" span={6}>
<Chip <UserProfileInheritedRoles
showNoDataPlaceholder inheritedRoles={userData.inheritedRoles}
data={defaultPersona ? [defaultPersona] : []} />
icon={<PersonaIcon height={18} />} </Col>
noDataPlaceholder={NO_DATA_PLACEHOLDER} <Col className="p-x-sm" span={6}>
/> <div className="d-flex flex-col justify-between h-full">
</Card> <Card
</div> className="ant-card-feed relative card-body-border-none card-padding-y-0"
</Col> title={
</Row> <Typography.Text
className="right-panel-label items-center d-flex gap-2"
data-testid="inherited-roles">
{t('label.persona')}
<PersonaSelectableList
multiSelect
hasPermission={Boolean(isAdminUser)}
selectedPersonas={userData.personas ?? []}
onUpdate={handlePersonaUpdate}
/>
</Typography.Text>
}>
<Chip
showNoDataPlaceholder
data={userData.personas ?? []}
icon={<PersonaIcon height={14} />}
noDataPlaceholder={t('message.no-persona-assigned')}
/>
</Card>
</div>
</Col>
</Row>
</Col>
<Col className="border-top p-lg p-b-0" span={24}>
{descriptionRenderComponent}
</Col>
</Row>
</Collapse.Panel>
</Collapse>
<Tabs <Tabs
destroyInactiveTabPane destroyInactiveTabPane
activeKey={activeTab ?? UserPageTabs.ACTIVITY} activeKey={activeTab ?? UserPageTabs.ACTIVITY}

View File

@ -30,17 +30,14 @@
} }
.user-layout { .user-layout {
.ant-col { > .ant-col {
padding-top: 0; padding-top: 0;
} }
.user-profile-container { .user-profile-container {
padding: 30px 0;
background: @user-profile-background;
.profile-image-container { .profile-image-container {
width: 190px; width: 60px;
height: 190px; height: 60px;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
border: 3px solid @white; border: 3px solid @white;

View File

@ -11,21 +11,20 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Col, Input, Row, Space, Typography } from 'antd'; import { Button, Divider, Input, Space, Typography } from 'antd';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ReactComponent as EditIcon } from '../../../../assets/svg/edit-new.svg'; import { ReactComponent as EditIcon } from '../../../../assets/svg/edit-new.svg';
import { useAuthContext } from '../../../../components/authentication/auth-provider/AuthProvider'; import { useAuthContext } from '../../../../components/authentication/auth-provider/AuthProvider';
import DescriptionV1 from '../../../../components/common/description/DescriptionV1';
import InlineEdit from '../../../../components/InlineEdit/InlineEdit.component'; import InlineEdit from '../../../../components/InlineEdit/InlineEdit.component';
import ChangePasswordForm from '../../../../components/Users/ChangePasswordForm'; import ChangePasswordForm from '../../../../components/Users/ChangePasswordForm';
import { import {
DE_ACTIVE_COLOR, DE_ACTIVE_COLOR,
ICON_DIMENSION, ICON_DIMENSION,
NO_DATA_PLACEHOLDER,
} from '../../../../constants/constants'; } from '../../../../constants/constants';
import { EntityType } from '../../../../enums/entity.enum';
import { import {
ChangePasswordRequest, ChangePasswordRequest,
RequestType, RequestType,
@ -36,6 +35,9 @@ import { useAuth } from '../../../../hooks/authHooks';
import { changePassword } from '../../../../rest/auth-API'; import { changePassword } from '../../../../rest/auth-API';
import { getEntityName } from '../../../../utils/EntityUtils'; import { getEntityName } from '../../../../utils/EntityUtils';
import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils'; import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils';
import Chip from '../../../common/Chip/Chip.component';
import { PersonaSelectableList } from '../../../Persona/PersonaSelectableList/PersonaSelectableList.component';
import UserProfileImage from '../UserProfileImage/UserProfileImage.component';
import { UserProfileDetailsProps } from './UserProfileDetails.interface'; import { UserProfileDetailsProps } from './UserProfileDetails.interface';
const UserProfileDetails = ({ const UserProfileDetails = ({
@ -52,7 +54,11 @@ const UserProfileDetails = ({
const [isChangePassword, setIsChangePassword] = useState<boolean>(false); const [isChangePassword, setIsChangePassword] = useState<boolean>(false);
const [displayName, setDisplayName] = useState(userData.displayName); const [displayName, setDisplayName] = useState(userData.displayName);
const [isDisplayNameEdit, setIsDisplayNameEdit] = useState(false); const [isDisplayNameEdit, setIsDisplayNameEdit] = useState(false);
const [isDescriptionEdit, setIsDescriptionEdit] = useState(false);
const isSelfProfileView = useMemo(
() => userData?.id === currentUser?.id,
[userData, currentUser]
);
const isAuthProviderBasic = useMemo( const isAuthProviderBasic = useMemo(
() => () =>
@ -71,28 +77,38 @@ const UserProfileDetails = ({
[isAdminUser, isLoggedInUser] [isAdminUser, isLoggedInUser]
); );
const onDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { const hasPersonaEditPermission = useMemo(
() => isAdminUser || isSelfProfileView,
[isAdminUser, isSelfProfileView]
);
const showChangePasswordComponent = useMemo(
() => isAuthProviderBasic && hasEditPermission,
[isAuthProviderBasic, hasEditPermission]
);
const defaultPersona = useMemo(
() =>
userData.personas?.find(
(persona) => persona.id === userData.defaultPersona?.id
),
[userData]
);
const onDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setDisplayName(e.target.value); setDisplayName(e.target.value);
};
const handleDescriptionChange = async (description: string) => { const handleDisplayNameSave = useCallback(() => {
await updateUserDetails({ description });
setIsDescriptionEdit(false);
};
const handleDisplayNameSave = () => {
if (displayName !== userData.displayName) { if (displayName !== userData.displayName) {
updateUserDetails({ displayName: displayName ?? '' }); updateUserDetails({ displayName: displayName ?? '' });
} }
setIsDisplayNameEdit(false); setIsDisplayNameEdit(false);
}; }, [userData.displayName, displayName, updateUserDetails]);
const displayNameRenderComponent = useMemo( const displayNameRenderComponent = useMemo(
() => () =>
isDisplayNameEdit && hasEditPermission ? ( isDisplayNameEdit && hasEditPermission ? (
<InlineEdit <InlineEdit
direction="vertical"
onCancel={() => setIsDisplayNameEdit(false)} onCancel={() => setIsDisplayNameEdit(false)}
onSave={handleDisplayNameSave}> onSave={handleDisplayNameSave}>
<Input <Input
@ -107,32 +123,31 @@ const UserProfileDetails = ({
/> />
</InlineEdit> </InlineEdit>
) : ( ) : (
<Row align="middle" wrap={false}> <Space align="center">
<Col flex="auto"> <Typography.Text
<Typography.Text className="font-medium text-md"
className="text-lg font-medium" data-testid="user-name"
ellipsis={{ tooltip: true }}> ellipsis={{ tooltip: true }}
{hasEditPermission style={{ maxWidth: '400px' }}>
? userData.displayName || {hasEditPermission
t('label.add-entity', { entity: t('label.display-name') }) ? userData.displayName ||
: getEntityName(userData)} t('label.add-entity', { entity: t('label.display-name') })
</Typography.Text> : getEntityName(userData)}
</Col> </Typography.Text>
<Col className="d-flex justify-end" flex="25px"> {hasEditPermission && (
{hasEditPermission && ( <EditIcon
<EditIcon className="cursor-pointer align-middle"
className="cursor-pointer" color={DE_ACTIVE_COLOR}
color={DE_ACTIVE_COLOR} data-testid="edit-displayName"
data-testid="edit-displayName" {...ICON_DIMENSION}
{...ICON_DIMENSION} onClick={() => setIsDisplayNameEdit(true)}
onClick={() => setIsDisplayNameEdit(true)} />
/> )}
)} </Space>
</Col>
</Row>
), ),
[ [
userData, userData,
displayName,
isDisplayNameEdit, isDisplayNameEdit,
hasEditPermission, hasEditPermission,
getEntityName, getEntityName,
@ -141,48 +156,12 @@ const UserProfileDetails = ({
] ]
); );
const descriptionRenderComponent = useMemo(
() =>
hasEditPermission ? (
<DescriptionV1
reduceDescription
description={userData.description ?? ''}
entityName={getEntityName(userData as unknown as EntityReference)}
entityType={EntityType.USER}
hasEditAccess={isAdminUser}
isEdit={isDescriptionEdit}
showCommentsIcon={false}
onCancel={() => setIsDescriptionEdit(false)}
onDescriptionEdit={() => setIsDescriptionEdit(true)}
onDescriptionUpdate={handleDescriptionChange}
/>
) : (
<Typography.Paragraph className="m-b-0">
{userData.description ?? (
<span className="text-grey-muted">
{t('label.no-entity', {
entity: t('label.description'),
})}
</span>
)}
</Typography.Paragraph>
),
[
userData,
isAdminUser,
isDescriptionEdit,
hasEditPermission,
getEntityName,
handleDescriptionChange,
]
);
const changePasswordRenderComponent = useMemo( const changePasswordRenderComponent = useMemo(
() => () =>
isAuthProviderBasic && showChangePasswordComponent && (
(isAdminUser || isLoggedInUser) && (
<Button <Button
className="w-full text-xs" className="w-full text-xs"
data-testid="change-password-button"
type="primary" type="primary"
onClick={() => setIsChangePassword(true)}> onClick={() => setIsChangePassword(true)}>
{t('label.change-entity', { {t('label.change-entity', {
@ -190,7 +169,7 @@ const UserProfileDetails = ({
})} })}
</Button> </Button>
), ),
[isAuthProviderBasic, isAdminUser, isLoggedInUser] [showChangePasswordComponent]
); );
const handleChangePassword = async (data: ChangePasswordRequest) => { const handleChangePassword = async (data: ChangePasswordRequest) => {
@ -216,34 +195,93 @@ const UserProfileDetails = ({
} }
}; };
return ( const userEmailRender = useMemo(
<Space () => (
className="p-sm w-full" <Space align="center">
data-testid="user-profile-details" <Typography.Text
direction="vertical" className="text-grey-muted"
size="middle"> data-testid="user-email-label">{`${t(
<Space className="w-full" direction="vertical" size={2}> 'label.email'
{displayNameRenderComponent} )} :`}</Typography.Text>
<Typography.Paragraph
className="m-b-0 text-grey-muted" <Typography.Paragraph className="m-b-0" data-testid="user-email-value">
ellipsis={{ tooltip: true }}>
{userData.email} {userData.email}
</Typography.Paragraph> </Typography.Paragraph>
</Space> </Space>
),
[userData.email]
);
const handleDefaultPersonaUpdate = useCallback(
async (defaultPersona?: EntityReference) => {
await updateUserDetails({ ...userData, defaultPersona });
},
[updateUserDetails, userData]
);
const defaultPersonaRender = useMemo(
() => (
<Space align="center">
<Typography.Text
className="text-grey-muted"
data-testid="default-persona-label">
{`${t('label.default-persona')} :`}
</Typography.Text>
<Chip
showNoDataPlaceholder
data={defaultPersona ? [defaultPersona] : []}
noDataPlaceholder={NO_DATA_PLACEHOLDER}
/>
<PersonaSelectableList
hasPermission={hasPersonaEditPermission}
multiSelect={false}
personaList={userData.personas}
selectedPersonas={defaultPersona ? [defaultPersona] : []}
onUpdate={handleDefaultPersonaUpdate}
/>
</Space>
),
[
userData.personas,
hasPersonaEditPermission,
defaultPersona,
handleDefaultPersonaUpdate,
]
);
return (
<>
<Space
wrap
className="w-full justify-between"
data-testid="user-profile-details"
size="middle">
<Space className="w-full">
<UserProfileImage userData={userData} />
{displayNameRenderComponent}
<Divider type="vertical" />
{userEmailRender}
<Divider type="vertical" />
{defaultPersonaRender}
</Space>
<Space direction="vertical" size="middle">
{descriptionRenderComponent}
{changePasswordRenderComponent} {changePasswordRenderComponent}
</Space> </Space>
<ChangePasswordForm {showChangePasswordComponent && (
isLoading={isLoading} <ChangePasswordForm
isLoggedInUser={isLoggedInUser} isLoading={isLoading}
visible={isChangePassword} isLoggedInUser={isLoggedInUser}
onCancel={() => setIsChangePassword(false)} visible={isChangePassword}
onSave={(data) => handleChangePassword(data)} onCancel={() => setIsChangePassword(false)}
/> onSave={(data) => handleChangePassword(data)}
</Space> />
)}
</>
); );
}; };

View File

@ -14,11 +14,6 @@
import { User } from '../../../../generated/entity/teams/user'; import { User } from '../../../../generated/entity/teams/user';
export interface UserProfileDetailsProps { export interface UserProfileDetailsProps {
userData: { userData: User;
email: string;
name: string;
displayName?: string;
description?: string;
};
updateUserDetails: (data: Partial<User>) => Promise<void>; updateUserDetails: (data: Partial<User>) => Promise<void>;
} }

View File

@ -0,0 +1,200 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '../../../../generated/settings/settings';
import { useAuth } from '../../../../hooks/authHooks';
import { USER_DATA } from '../../../../mocks/User.mock';
import { useAuthContext } from '../../../authentication/auth-provider/AuthProvider';
import UserProfileDetails from './UserProfileDetails.component';
import { UserProfileDetailsProps } from './UserProfileDetails.interface';
const mockParams = {
fqn: 'test',
};
const mockPropsData: UserProfileDetailsProps = {
userData: USER_DATA,
updateUserDetails: jest.fn(),
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockImplementation(() => mockParams),
}));
jest.mock(
'../../../../components/authentication/auth-provider/AuthProvider',
() => ({
useAuthContext: jest.fn(() => ({
authConfig: {
provider: AuthProvider.Basic,
},
currentUser: {
name: 'test',
},
})),
})
);
jest.mock('../../../../hooks/authHooks', () => ({
useAuth: jest.fn().mockReturnValue({ isAdminUser: true }),
}));
jest.mock('../../../../utils/EntityUtils', () => ({
getEntityName: jest.fn().mockReturnValue('entityName'),
}));
jest.mock('../../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
showSuccessToast: jest.fn(),
}));
jest.mock('../UserProfileImage/UserProfileImage.component', () => {
return jest.fn().mockReturnValue(<p>ProfilePicture</p>);
});
jest.mock('../../../../components/InlineEdit/InlineEdit.component', () => {
return jest.fn().mockReturnValue(<p>InlineEdit</p>);
});
jest.mock('../../../../components/Users/ChangePasswordForm', () => {
return jest.fn().mockReturnValue(<p>ChangePasswordForm</p>);
});
jest.mock(
'../../../Persona/PersonaSelectableList/PersonaSelectableList.component',
() => ({
PersonaSelectableList: jest
.fn()
.mockReturnValue(<p>PersonaSelectableList</p>),
})
);
jest.mock('../../../common/Chip/Chip.component', () => {
return jest.fn().mockReturnValue(<p>Chip</p>);
});
jest.mock('../UserProfileImage/UserProfileImage.component', () => {
return jest.fn().mockReturnValue(<p>UserProfileImage</p>);
});
jest.mock('../../../../rest/auth-API', () => ({
changePassword: jest.fn(),
}));
describe('Test User Profile Details Component', () => {
it('Should render user profile details component', async () => {
render(<UserProfileDetails {...mockPropsData} />, {
wrapper: MemoryRouter,
});
expect(screen.getByTestId('user-profile-details')).toBeInTheDocument();
expect(screen.getByText('ChangePasswordForm')).toBeInTheDocument();
});
it('Should render user data component', async () => {
render(<UserProfileDetails {...mockPropsData} />, {
wrapper: MemoryRouter,
});
expect(screen.getByTestId('user-profile-details')).toBeInTheDocument();
// if user doesn't have displayname
expect(screen.getByTestId('user-name')).toContainHTML('label.add-entity');
expect(screen.getByTestId('edit-displayName')).toBeInTheDocument();
// user email
expect(screen.getByTestId('user-email-label')).toBeInTheDocument();
expect(screen.getByTestId('user-email-value')).toContainHTML(
USER_DATA.email
);
// user default persona along with edit
expect(screen.getByTestId('default-persona-label')).toBeInTheDocument();
expect(screen.getByText('PersonaSelectableList')).toBeInTheDocument();
expect(screen.getByTestId('change-password-button')).toBeInTheDocument();
});
it('should not render change password button and component in case of SSO', async () => {
(useAuthContext as jest.Mock).mockImplementationOnce(() => ({
authConfig: jest.fn().mockImplementationOnce(() => ({
provider: AuthProvider.Google,
})),
}));
render(<UserProfileDetails {...mockPropsData} />, {
wrapper: MemoryRouter,
});
expect(
screen.queryByTestId('change-password-button')
).not.toBeInTheDocument();
expect(screen.queryByText('ChangePasswordForm')).not.toBeInTheDocument();
});
it('should not provide edit access if non admin user and not current user', async () => {
(useAuth as jest.Mock).mockImplementationOnce(() => ({
isAdminUser: false,
}));
(useAuthContext as jest.Mock).mockImplementationOnce(() => ({
currentUser: {
name: 'admin',
id: '1234',
},
}));
render(<UserProfileDetails {...mockPropsData} />, {
wrapper: MemoryRouter,
});
expect(screen.getByTestId('user-profile-details')).toBeInTheDocument();
// render user name with no edit if doesn't have edit access
expect(screen.getByTestId('user-name')).toContainHTML('entityName');
expect(screen.queryByTestId('edit-displayName')).not.toBeInTheDocument();
// render chip in case of no default persona to other user
expect(screen.getByTestId('default-persona-label')).toBeInTheDocument();
expect(screen.getByText('Chip')).toBeInTheDocument();
expect(screen.getByText('PersonaSelectableList')).toBeInTheDocument();
expect(
screen.queryByTestId('change-password-button')
).not.toBeInTheDocument();
});
it('should render edit display name input on click', async () => {
render(<UserProfileDetails {...mockPropsData} />, {
wrapper: MemoryRouter,
});
expect(screen.getByTestId('user-profile-details')).toBeInTheDocument();
expect(screen.getByTestId('user-name')).toContainHTML('label.add-entity');
const editButton = screen.getByTestId('edit-displayName');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
expect(screen.getByText('InlineEdit')).toBeInTheDocument();
});
});

View File

@ -21,7 +21,7 @@ import {
import { UserProfileImageProps } from './UserProfileImage.interface'; import { UserProfileImageProps } from './UserProfileImage.interface';
const UserProfileImage = ({ userData }: UserProfileImageProps) => { const UserProfileImage = ({ userData }: UserProfileImageProps) => {
const [isImgUrlValid, SetIsImgUrlValid] = useState<boolean>(true); const [isImgUrlValid, setIsImgUrlValid] = useState<boolean>(true);
const image = useMemo( const image = useMemo(
() => () =>
@ -30,31 +30,32 @@ const UserProfileImage = ({ userData }: UserProfileImageProps) => {
); );
useEffect(() => { useEffect(() => {
if (image) { setIsImgUrlValid(Boolean(image));
SetIsImgUrlValid(true);
}
}, [image]); }, [image]);
return ( return (
<div className="profile-image-container"> <div
className="profile-image-container"
data-testid="profile-image-container">
{isImgUrlValid ? ( {isImgUrlValid ? (
<Image <Image
alt="profile" alt="profile"
data-testid="user-profile-image"
preview={false} preview={false}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
src={image ?? ''} src={image ?? ''}
onError={() => { onError={() => {
SetIsImgUrlValid(false); setIsImgUrlValid(false);
}} }}
/> />
) : ( ) : (
<ProfilePicture <ProfilePicture
displayName={userData?.displayName ?? userData.name} displayName={userData?.displayName ?? userData.name}
height="186" height="54"
id={userData?.id ?? ''} id={userData?.id ?? ''}
name={userData?.name ?? ''} name={userData?.name ?? ''}
textClass="text-5xl" textClass="text-xl"
width="" width="54"
/> />
)} )}
</div> </div>

View File

@ -0,0 +1,62 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { getImageWithResolutionAndFallback } from '../../../../utils/ProfilerUtils';
import { mockUserData } from '../../mocks/User.mocks';
import UserProfileImage from './UserProfileImage.component';
import { UserProfileImageProps } from './UserProfileImage.interface';
const mockPropsData: UserProfileImageProps = {
userData: mockUserData,
};
jest.mock('../../../../utils/ProfilerUtils', () => ({
getImageWithResolutionAndFallback: jest.fn().mockImplementation(undefined),
ImageQuality: jest.fn().mockReturnValue('6x'),
}));
jest.mock('../../../../components/common/ProfilePicture/ProfilePicture', () => {
return jest.fn().mockReturnValue(<p>ProfilePicture</p>);
});
describe('Test User User Profile Image Component', () => {
it('should render user profile image component', async () => {
render(<UserProfileImage {...mockPropsData} />);
expect(screen.getByTestId('profile-image-container')).toBeInTheDocument();
});
it('should render user profile picture component if no image found', async () => {
await act(async () => {
render(<UserProfileImage {...mockPropsData} />);
});
expect(screen.getByTestId('profile-image-container')).toBeInTheDocument();
expect(screen.getByText('ProfilePicture')).toBeInTheDocument();
});
it('should render user profile picture component with image', async () => {
(getImageWithResolutionAndFallback as jest.Mock).mockImplementationOnce(
() => '/image/test/png'
);
render(<UserProfileImage {...mockPropsData} />);
expect(screen.getByTestId('profile-image-container')).toBeInTheDocument();
expect(screen.getByTestId('user-profile-image')).toBeInTheDocument();
});
});

View File

@ -31,7 +31,7 @@ const UserProfileInheritedRoles = ({
title={ title={
<Typography.Text <Typography.Text
className="right-panel-label m-b-0" className="right-panel-label m-b-0"
data-testid="inherited-roles"> data-testid="inherited-roles-label">
{t('label.inherited-role-plural')} {t('label.inherited-role-plural')}
</Typography.Text> </Typography.Text>
}> }>

View File

@ -0,0 +1,39 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import UserProfileInheritedRoles from './UserProfileInheritedRoles.component';
import { UserProfileInheritedRolesProps } from './UserProfileInheritedRoles.interface';
const mockPropsData: UserProfileInheritedRolesProps = {
inheritedRoles: [],
};
jest.mock('../../../common/Chip/Chip.component', () => {
return jest.fn().mockReturnValue(<p>Chip</p>);
});
describe('Test User Profile Roles Component', () => {
it('should render user profile roles component', async () => {
render(<UserProfileInheritedRoles {...mockPropsData} />);
expect(
screen.getByTestId('user-profile-inherited-roles')
).toBeInTheDocument();
expect(screen.getByTestId('inherited-roles-label')).toBeInTheDocument();
expect(await screen.findAllByText('Chip')).toHaveLength(1);
});
});

View File

@ -34,7 +34,6 @@ import { showErrorToast } from '../../../../utils/ToastUtils';
import { UserProfileRolesProps } from './UserProfileRoles.interface'; import { UserProfileRolesProps } from './UserProfileRoles.interface';
const UserProfileRoles = ({ const UserProfileRoles = ({
isUserAdmin,
userRoles, userRoles,
updateUserDetails, updateUserDetails,
}: UserProfileRolesProps) => { }: UserProfileRolesProps) => {
@ -53,7 +52,7 @@ const UserProfileRoles = ({
value: role.id, value: role.id,
})); }));
if (!isUserAdmin) { if (!isAdminUser) {
options.push({ options.push({
label: TERM_ADMIN, label: TERM_ADMIN,
value: toLower(TERM_ADMIN), value: toLower(TERM_ADMIN),
@ -61,7 +60,7 @@ const UserProfileRoles = ({
} }
return options; return options;
}, [roles]); }, [roles, isAdminUser, getEntityName]);
const fetchRoles = async () => { const fetchRoles = async () => {
setIsRolesLoading(true); setIsRolesLoading(true);
@ -112,27 +111,27 @@ const UserProfileRoles = ({
() => ( () => (
<Chip <Chip
data={[ data={[
...(isUserAdmin ...(isAdminUser
? [{ id: 'admin', type: 'role', name: TERM_ADMIN }] ? [{ id: 'admin', type: 'role', name: TERM_ADMIN }]
: []), : []),
...(userRoles ?? []), ...(userRoles ?? []),
]} ]}
icon={<UserIcons height={20} />} icon={<UserIcons height={20} />}
noDataPlaceholder={t('message.no-roles-assigned')} noDataPlaceholder={t('message.no-roles-assigned')}
showNoDataPlaceholder={!isUserAdmin} showNoDataPlaceholder={!isAdminUser}
/> />
), ),
[userRoles, isUserAdmin] [userRoles, isAdminUser]
); );
useEffect(() => { useEffect(() => {
const defaultUserRoles = [ const defaultUserRoles = [
...(userRoles?.map((role) => role.id) ?? []), ...(userRoles?.map((role) => role.id) ?? []),
...(isUserAdmin ? [toLower(TERM_ADMIN)] : []), ...(isAdminUser ? [toLower(TERM_ADMIN)] : []),
]; ];
setSelectedRoles(defaultUserRoles); setSelectedRoles(defaultUserRoles);
}, [isUserAdmin, userRoles]); }, [isAdminUser, userRoles]);
useEffect(() => { useEffect(() => {
if (isRolesEdit && isEmpty(roles)) { if (isRolesEdit && isEmpty(roles)) {
@ -154,7 +153,7 @@ const UserProfileRoles = ({
<EditIcon <EditIcon
className="cursor-pointer align-middle" className="cursor-pointer align-middle"
color={DE_ACTIVE_COLOR} color={DE_ACTIVE_COLOR}
data-testid="edit-roles" data-testid="edit-roles-button"
{...ICON_DIMENSION} {...ICON_DIMENSION}
onClick={() => setIsRolesEdit(true)} onClick={() => setIsRolesEdit(true)}
/> />

View File

@ -13,7 +13,6 @@
import { User } from '../../../../generated/entity/teams/user'; import { User } from '../../../../generated/entity/teams/user';
export interface UserProfileRolesProps { export interface UserProfileRolesProps {
isUserAdmin?: boolean;
userRoles: User['roles']; userRoles: User['roles'];
updateUserDetails: (data: Partial<User>) => Promise<void>; updateUserDetails: (data: Partial<User>) => Promise<void>;
} }

View File

@ -0,0 +1,123 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { useAuth } from '../../../../hooks/authHooks';
import { getRoles } from '../../../../rest/rolesAPIV1';
import { mockUserRole } from '../../mocks/User.mocks';
import UserProfileRoles from './UserProfileRoles.component';
import { UserProfileRolesProps } from './UserProfileRoles.interface';
const mockPropsData: UserProfileRolesProps = {
userRoles: [],
updateUserDetails: jest.fn(),
};
jest.mock('../../../../hooks/authHooks', () => ({
useAuth: jest.fn().mockReturnValue({ isAdminUser: false }),
}));
jest.mock('../../../../components/InlineEdit/InlineEdit.component', () => {
return jest.fn().mockReturnValue(<p>InlineEdit</p>);
});
jest.mock('../../../common/Chip/Chip.component', () => {
return jest.fn().mockReturnValue(<p>Chip</p>);
});
jest.mock('../../../../utils/EntityUtils', () => ({
getEntityName: jest.fn().mockReturnValue('roleName'),
}));
jest.mock('../../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('../../../../rest/rolesAPIV1', () => ({
getRoles: jest.fn().mockImplementation(() => Promise.resolve(mockUserRole)),
}));
describe('Test User Profile Roles Component', () => {
it('should render user profile roles component', async () => {
render(<UserProfileRoles {...mockPropsData} />);
expect(screen.getByTestId('user-profile-roles')).toBeInTheDocument();
});
it('should render chip component', async () => {
render(<UserProfileRoles {...mockPropsData} />);
expect(screen.getByTestId('user-profile-roles')).toBeInTheDocument();
expect(await screen.findAllByText('Chip')).toHaveLength(1);
});
it('should not render roles edit button if non admin user', async () => {
render(<UserProfileRoles {...mockPropsData} />);
expect(screen.getByTestId('user-profile-roles')).toBeInTheDocument();
expect(screen.queryByTestId('edit-roles-button')).not.toBeInTheDocument();
});
it('should render edit button if admin user', async () => {
(useAuth as jest.Mock).mockImplementation(() => ({
isAdminUser: true,
}));
render(<UserProfileRoles {...mockPropsData} />);
expect(screen.getByTestId('user-profile-roles')).toBeInTheDocument();
expect(screen.getByTestId('edit-roles-button')).toBeInTheDocument();
});
it('should render select field on edit button action', async () => {
(useAuth as jest.Mock).mockImplementation(() => ({
isAdminUser: true,
}));
render(<UserProfileRoles {...mockPropsData} />);
expect(screen.getByTestId('user-profile-roles')).toBeInTheDocument();
const editButton = screen.getByTestId('edit-roles-button');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
expect(screen.getByText('InlineEdit')).toBeInTheDocument();
});
it('should call roles api on edit button action', async () => {
(useAuth as jest.Mock).mockImplementation(() => ({
isAdminUser: true,
}));
render(<UserProfileRoles {...mockPropsData} />);
expect(screen.getByTestId('user-profile-roles')).toBeInTheDocument();
const editButton = screen.getByTestId('edit-roles-button');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
expect(getRoles).toHaveBeenCalledWith('', undefined, undefined, false, 50);
expect(screen.getByText('InlineEdit')).toBeInTheDocument();
});
});

View File

@ -64,6 +64,7 @@ const UserProfileTeams = ({
return ( return (
<Card <Card
className="relative card-body-border-none card-padding-y-0" className="relative card-body-border-none card-padding-y-0"
data-testid="user-team-card-container"
key="teams-card" key="teams-card"
title={ title={
<Space align="center"> <Space align="center">
@ -75,30 +76,28 @@ const UserProfileTeams = ({
<EditIcon <EditIcon
className="cursor-pointer align-middle" className="cursor-pointer align-middle"
color={DE_ACTIVE_COLOR} color={DE_ACTIVE_COLOR}
data-testid="edit-teams" data-testid="edit-teams-button"
{...ICON_DIMENSION} {...ICON_DIMENSION}
onClick={() => setIsTeamsEdit(true)} onClick={() => setIsTeamsEdit(true)}
/> />
)} )}
</Space> </Space>
}> }>
<div className="m-b-md"> {isTeamsEdit && isAdminUser ? (
{isTeamsEdit && isAdminUser ? ( <InlineEdit
<InlineEdit direction="vertical"
direction="vertical" onCancel={() => setIsTeamsEdit(false)}
onCancel={() => setIsTeamsEdit(false)} onSave={handleTeamsSave}>
onSave={handleTeamsSave}> <TeamsSelectable
<TeamsSelectable filterJoinable
filterJoinable maxValueCount={4}
maxValueCount={4} selectedTeams={selectedTeams}
selectedTeams={selectedTeams} onSelectionChange={setSelectedTeams}
onSelectionChange={setSelectedTeams} />
/> </InlineEdit>
</InlineEdit> ) : (
) : ( teamsRenderElement
teamsRenderElement )}
)}
</div>
</Card> </Card>
); );
}; };

View File

@ -0,0 +1,88 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { useAuth } from '../../../../hooks/authHooks';
import { USER_DATA, USER_TEAMS } from '../../../../mocks/User.mock';
import UserProfileTeams from './UserProfileTeams.component';
import { UserProfileTeamsProps } from './UserProfileTeams.interface';
const mockPropsData: UserProfileTeamsProps = {
teams: [],
updateUserDetails: jest.fn(),
};
jest.mock('../../../../hooks/authHooks', () => ({
useAuth: jest.fn().mockReturnValue({ isAdminUser: true }),
}));
jest.mock('../../../../utils/CommonUtils', () => ({
getNonDeletedTeams: jest.fn(),
}));
jest.mock('../../../../components/InlineEdit/InlineEdit.component', () => {
return jest.fn().mockReturnValue(<p>InlineEdit</p>);
});
jest.mock('../../../common/Chip/Chip.component', () => {
return jest.fn().mockReturnValue(<p>Chip</p>);
});
jest.mock('../../../../components/TeamsSelectable/TeamsSelectable', () => {
return jest.fn().mockReturnValue(<p>TeamsSelectable</p>);
});
describe('Test User Profile Teams Component', () => {
it('should render user profile teams component', async () => {
render(<UserProfileTeams {...mockPropsData} />);
expect(screen.getByTestId('user-team-card-container')).toBeInTheDocument();
});
it('should render teams if data available', async () => {
render(<UserProfileTeams {...mockPropsData} teams={USER_DATA.teams} />);
expect(screen.getByTestId('user-team-card-container')).toBeInTheDocument();
expect(screen.getByTestId('edit-teams-button')).toBeInTheDocument();
expect(await screen.findAllByText('Chip')).toHaveLength(1);
});
it('should render teams select input on edit click', async () => {
render(<UserProfileTeams {...mockPropsData} teams={USER_DATA.teams} />);
expect(screen.getByTestId('user-team-card-container')).toBeInTheDocument();
const editButton = screen.getByTestId('edit-teams-button');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
expect(screen.getByText('InlineEdit')).toBeInTheDocument();
});
it('should not render edit button to non admin user', async () => {
(useAuth as jest.Mock).mockImplementation(() => ({
isAdminUser: false,
}));
render(<UserProfileTeams {...mockPropsData} teams={USER_TEAMS} />);
expect(screen.getByTestId('user-team-card-container')).toBeInTheDocument();
expect(screen.queryByTestId('edit-teams-button')).not.toBeInTheDocument();
});
});

View File

@ -10,10 +10,13 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { Popover, Space, Tag, Typography } from 'antd'; import { Col, Popover, Row, Space, Tag, Typography } from 'antd';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { USER_DATA_SIZE } from '../../../constants/constants'; import {
NO_DATA_PLACEHOLDER,
USER_DATA_SIZE,
} from '../../../constants/constants';
import { EntityReference } from '../../../generated/entity/type'; import { EntityReference } from '../../../generated/entity/type';
import { getEntityName } from '../../../utils/EntityUtils'; import { getEntityName } from '../../../utils/EntityUtils';
import { ChipProps } from './Chip.interface'; import { ChipProps } from './Chip.interface';
@ -32,15 +35,15 @@ const Chip = ({
); );
const getChipElement = (item: EntityReference) => ( const getChipElement = (item: EntityReference) => (
<div <Col
className="w-full d-flex items-center gap-2" className="d-flex gap-1 items-center"
data-testid={item.name} data-testid={item.name}
key={item.name}> key={item.name}>
{icon} {icon}
<Typography.Text className="w-56 text-left" ellipsis={{ tooltip: true }}> <Typography.Text className="text-left">
{getEntityName(item)} {getEntityName(item)}
</Typography.Text> </Typography.Text>
</div> </Col>
); );
useEffect(() => { useEffect(() => {
@ -49,14 +52,18 @@ const Chip = ({
if (isEmpty(data) && showNoDataPlaceholder) { if (isEmpty(data) && showNoDataPlaceholder) {
return ( return (
<Typography.Paragraph className="text-grey-muted"> <Typography.Paragraph className="text-grey-muted m-b-0">
{noDataPlaceholder} {noDataPlaceholder ?? NO_DATA_PLACEHOLDER}
</Typography.Paragraph> </Typography.Paragraph>
); );
} }
return ( return (
<Space wrap data-testid="chip-container" size={4}> <Row
wrap
className="align-middle"
data-testid="chip-container"
gutter={[20, 6]}>
{data.slice(0, USER_DATA_SIZE).map(getChipElement)} {data.slice(0, USER_DATA_SIZE).map(getChipElement)}
{hasMoreElement && ( {hasMoreElement && (
<Popover <Popover
@ -73,7 +80,7 @@ const Chip = ({
} more`}</Tag> } more`}</Tag>
</Popover> </Popover>
)} )}
</Space> </Row>
); );
}; };

View File

@ -14,7 +14,7 @@ import { EntityReference } from '../../../generated/entity/type';
export interface ChipProps { export interface ChipProps {
data: EntityReference[]; data: EntityReference[];
icon: React.ReactElement; icon?: React.ReactElement;
noDataPlaceholder: string; noDataPlaceholder?: string;
showNoDataPlaceholder?: boolean; showNoDataPlaceholder?: boolean;
} }

View File

@ -49,12 +49,12 @@ import mode from '../assets/img/service-icon-mode.png';
import mongodb from '../assets/img/service-icon-mongodb.png'; import mongodb from '../assets/img/service-icon-mongodb.png';
import msAzure from '../assets/img/service-icon-ms-azure.png'; import msAzure from '../assets/img/service-icon-ms-azure.png';
import mssql from '../assets/img/service-icon-mssql.png'; import mssql from '../assets/img/service-icon-mssql.png';
import mstr from '../assets/img/service-icon-mstr.png';
import nifi from '../assets/img/service-icon-nifi.png'; import nifi from '../assets/img/service-icon-nifi.png';
import oracle from '../assets/img/service-icon-oracle.png'; import oracle from '../assets/img/service-icon-oracle.png';
import pinot from '../assets/img/service-icon-pinot.png'; import pinot from '../assets/img/service-icon-pinot.png';
import postgres from '../assets/img/service-icon-post.png'; import postgres from '../assets/img/service-icon-post.png';
import powerbi from '../assets/img/service-icon-power-bi.png'; import powerbi from '../assets/img/service-icon-power-bi.png';
import mstr from '../assets/img/service-icon-mstr.png';
import prefect from '../assets/img/service-icon-prefect.png'; import prefect from '../assets/img/service-icon-prefect.png';
import presto from '../assets/img/service-icon-presto.png'; import presto from '../assets/img/service-icon-presto.png';
import pulsar from '../assets/img/service-icon-pulsar.png'; import pulsar from '../assets/img/service-icon-pulsar.png';

View File

@ -49,7 +49,7 @@ export const LOGGED_IN_USER_STORAGE_KEY = 'loggedInUsers';
export const ACTIVE_DOMAIN_STORAGE_KEY = 'activeDomain'; export const ACTIVE_DOMAIN_STORAGE_KEY = 'activeDomain';
export const DEFAULT_DOMAIN_VALUE = 'All Domains'; export const DEFAULT_DOMAIN_VALUE = 'All Domains';
export const USER_DATA_SIZE = 4; export const USER_DATA_SIZE = 5;
export const INITIAL_PAGING_VALUE = 1; export const INITIAL_PAGING_VALUE = 1;
export const JSON_TAB_SIZE = 2; export const JSON_TAB_SIZE = 2;
export const PAGE_SIZE = 10; export const PAGE_SIZE = 10;

View File

@ -0,0 +1,142 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { User } from '../generated/entity/teams/user';
export const USER_DATA: User = {
id: '7f196a28-c4fa-4579-b420-f828985e7861',
name: 'admin',
fullyQualifiedName: 'admin',
description: '',
displayName: '',
version: 3.3,
updatedAt: 1698655259882,
updatedBy: 'admin',
email: 'admin@openmetadata.org',
href: 'http://localhost:8585/api/v1/users/7f196a28-c4fa-4579-b420-f828985e7861',
isBot: false,
isAdmin: true,
teams: [
{
id: '9e8b7464-3f3e-4071-af05-19be142d75db',
type: 'team',
name: 'Organization',
fullyQualifiedName: 'Organization',
description:
'Organization under which all the other team hierarchy is created',
displayName: 'Organization',
deleted: false,
href: 'http://localhost:8585/api/v1/teams/9e8b7464-3f3e-4071-af05-19be142d75db',
},
],
personas: [
{
id: '0430976d-092a-46c9-90a8-61c6091a6f38',
type: 'persona',
name: 'Person-04',
fullyQualifiedName: 'Person-04',
description: 'Person-04',
displayName: 'Person-04',
href: 'http://localhost:8585/api/v1/personas/0430976d-092a-46c9-90a8-61c6091a6f38',
},
],
defaultPersona: {
description: 'Person-04',
displayName: 'Person-04',
fullyQualifiedName: 'Person-04',
href: 'http://localhost:8585/api/v1/personas/0430976d-092a-46c9-90a8-61c6091a6f38',
id: '0430976d-092a-46c9-90a8-61c6091a6f38',
name: 'Person-04',
type: 'persona',
},
changeDescription: {
fieldsAdded: [],
fieldsUpdated: [],
fieldsDeleted: [],
previousVersion: 3.2,
},
deleted: false,
roles: [
{
id: 'ed94fd7c-0974-4b87-9295-02b36c4c6bcd',
type: 'role',
name: 'DataConsumer',
fullyQualifiedName: 'DataConsumer',
description:
'Users with Data Consumer role use different data assets for their day to day work.',
displayName: 'Data Consumer',
deleted: false,
href: 'http://localhost:8585/api/v1/roles/ed94fd7c-0974-4b87-9295-02b36c4c6bcd',
},
{
id: 'a24f61cc-be15-411a-aaf6-28a8c8029728',
type: 'role',
name: 'DataSteward',
fullyQualifiedName: 'DataSteward',
description: 'this is test description',
displayName: 'Data Steward',
deleted: false,
href: 'http://localhost:8585/api/v1/roles/a24f61cc-be15-411a-aaf6-28a8c8029728',
},
{
id: '257b6976-26c6-4397-b025-087b95e45788',
type: 'role',
name: 'IngestionBotRole',
fullyQualifiedName: 'IngestionBotRole',
description: 'Role corresponding to a Ingestion bot.',
displayName: 'Ingestion bot role',
deleted: false,
href: 'http://localhost:8585/api/v1/roles/257b6976-26c6-4397-b025-087b95e45788',
},
],
inheritedRoles: [
{
id: 'ed94fd7c-0974-4b87-9295-02b36c4c6bcd',
type: 'role',
name: 'DataConsumer',
fullyQualifiedName: 'DataConsumer',
description:
'Users with Data Consumer role use different data assets for their day to day work.',
displayName: 'Data Consumer',
deleted: false,
href: 'http://localhost:8585/api/v1/roles/ed94fd7c-0974-4b87-9295-02b36c4c6bcd',
},
],
isEmailVerified: true,
};
export const USER_TEAMS = [
{
id: '9e8b7464-3f3e-4071-af05-19be142d75db',
type: 'team',
name: 'Organization',
fullyQualifiedName: 'Organization',
description:
'Organization under which all the other team hierarchy is created',
displayName: 'Organization',
deleted: false,
href: 'http://localhost:8585/api/v1/teams/9e8b7464-3f3e-4071-af05-19be142d75db',
},
{
id: '9e8b7464-3f3e-4071-af05-19be142d75bc',
type: 'team',
name: 'Application',
fullyQualifiedName: 'Application',
description:
'Application under which all the other team hierarchy is created',
displayName: 'Application',
deleted: false,
href: 'http://localhost:8585/api/v1/teams/9e8b7464-3f3e-4071-af05-19be142d75bc',
},
];

View File

@ -745,3 +745,18 @@ a[href].link-text-grey,
width: 36px; width: 36px;
border-radius: 50%; border-radius: 50%;
} }
// Collapse able headers
.header-collapse-custom-collapse .header-collapse-custom-panel {
overflow: hidden;
padding: 0;
background: @user-profile-background;
border: 0px;
.ant-collapse-content {
.ant-collapse-content-box {
padding: 0;
}
}
}