feat: implemented export user functionality and refactor user tab (#11909)

* refactor user tab in team page

* added manage button and import export option for user tab

* integrated export for users from team

* added unit test for user tab

* addressing comments

* addressing comments
This commit is contained in:
Shailesh Parmar 2023-06-08 14:24:49 +05:30 committed by GitHub
parent 66aa28a3ee
commit 5404440e41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 513 additions and 154 deletions

View File

@ -20,22 +20,18 @@ import {
Row,
Space,
Switch,
Table,
Tabs,
Tooltip,
Typography,
} from 'antd';
import { ItemType } from 'antd/lib/menu/hooks/useItems';
import { ColumnsType } from 'antd/lib/table';
import { ReactComponent as IconEdit } from 'assets/svg/edit-new.svg';
import { ReactComponent as ExportIcon } from 'assets/svg/ic-export.svg';
import { ReactComponent as ImportIcon } from 'assets/svg/ic-import.svg';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import FilterTablePlaceHolder from 'components/common/error-with-placeholder/FilterTablePlaceHolder';
import { ManageButtonItemLabel } from 'components/common/ManageButtonContentItem/ManageButtonContentItem.component';
import TableDataCardV2 from 'components/common/table-data-card-v2/TableDataCardV2';
import { UserSelectableList } from 'components/common/UserSelectableList/UserSelectableList.component';
import { useEntityExportModalProvider } from 'components/Entity/EntityExportModalProvider/EntityExportModalProvider.component';
import {
GlobalSettingOptions,
@ -51,7 +47,6 @@ import {
isNil,
isUndefined,
lowerCase,
orderBy,
uniqueId,
} from 'lodash';
import { ExtraInfo } from 'Models';
@ -69,7 +64,6 @@ import { useHistory, useLocation } from 'react-router-dom';
import { getSuggestions } from 'rest/miscAPI';
import { exportTeam, restoreTeam } from 'rest/teamsAPI';
import AppState from '../../AppState';
import { ReactComponent as IconRemove } from '../../assets/svg/ic-remove.svg';
import { ReactComponent as IconRestore } from '../../assets/svg/ic-restore.svg';
import { ReactComponent as IconOpenLock } from '../../assets/svg/open-lock.svg';
import { ReactComponent as IconShowPassword } from '../../assets/svg/show-password.svg';
@ -77,7 +71,6 @@ import {
getTeamAndUserDetailsPath,
getUserPath,
LIST_SIZE,
PAGE_SIZE_MEDIUM,
ROUTES,
} from '../../constants/constants';
import { ROLE_DOCS, TEAMS_DOCS } from '../../constants/docs.constants';
@ -126,12 +119,12 @@ import {
OperationPermission,
ResourceEntity,
} from '../PermissionProvider/PermissionProvider.interface';
import { commonUserDetailColumns } from '../Users/Users.util';
import ListEntities from './RolesAndPoliciesList';
import { TeamsPageTab } from './team.interface';
import { getTabs } from './TeamDetailsV1.utils';
import TeamHierarchy from './TeamHierarchy';
import './teams.less';
import { UserTab } from './UserTab/UserTab.component';
const TeamDetailsV1 = ({
assets,
@ -316,42 +309,6 @@ const TeamDetailsV1 = ({
[]
);
const columns: ColumnsType<User> = useMemo(() => {
return [
...commonUserDetailColumns(),
{
title: t('label.action-plural'),
dataIndex: 'actions',
key: 'actions',
width: 90,
render: (_, record) => (
<Space
align="center"
className="tw-w-full tw-justify-center remove-icon"
size={8}>
<Tooltip
placement="bottomRight"
title={
entityPermissions.EditAll
? t('label.remove')
: t('message.no-permission-for-action')
}>
<Button
data-testid="remove-user-btn"
disabled={!entityPermissions.EditAll}
icon={
<IconRemove height={16} name={t('label.remove')} width={16} />
}
type="text"
onClick={() => deleteUserHandler(record.id)}
/>
</Tooltip>
</Space>
),
},
];
}, [deleteUserHandler]);
const ownerValue = useMemo(() => {
switch (currentTeam.owner?.type) {
case 'team':
@ -842,114 +799,6 @@ const TeamDetailsV1 = ({
]
);
/**
* Check for current team users and return the user cards
* @returns - user cards
*/
const getUserCards = () => {
const sortedUser = orderBy(currentTeamUsers || [], ['name'], 'asc');
return (
<>
{isEmpty(currentTeamUsers) &&
!teamUsersSearchText &&
isTeamMemberLoading <= 0 ? (
fetchErrorPlaceHolder({
type: ERROR_PLACEHOLDER_TYPE.ASSIGN,
permission: entityPermissions.EditAll,
heading: t('label.user'),
button: (
<UserSelectableList
hasPermission
selectedUsers={currentTeam.users ?? []}
onUpdate={handleAddUser}>
<Button
ghost
className="p-x-lg"
data-testid="add-new-user"
icon={<PlusOutlined />}
title={
entityPermissions.EditAll
? t('label.add-new-entity', { entity: t('label.user') })
: t('message.no-permission-for-action')
}
type="primary">
{t('label.add')}
</Button>
</UserSelectableList>
),
})
) : (
<>
<div className="d-flex tw-justify-between tw-items-center tw-mb-3">
<div className="tw-w-4/12">
<Searchbar
removeMargin
placeholder={t('label.search-for-type', {
type: t('label.user-lowercase'),
})}
searchValue={teamUsersSearchText}
typingInterval={500}
onSearch={handleTeamUsersSearchAction}
/>
</div>
{currentTeamUsers.length > 0 && isActionAllowed() && (
<UserSelectableList
hasPermission
selectedUsers={currentTeam.users ?? []}
onUpdate={handleAddUser}>
<Button
data-testid="add-new-user"
disabled={!entityPermissions.EditAll}
title={
entityPermissions.EditAll
? t('label.add-entity', { entity: t('label.user') })
: t('message.no-permission-for-action')
}
type="primary">
{t('label.add-entity', { entity: t('label.user') })}
</Button>
</UserSelectableList>
)}
</div>
{isTeamMemberLoading > 0 ? (
<Loader />
) : (
<div>
<Fragment>
<Table
bordered
className="teams-list-table"
columns={columns}
dataSource={sortedUser}
locale={{
emptyText: <FilterTablePlaceHolder />,
}}
pagination={false}
rowKey="name"
size="small"
/>
{teamUserPagin.total > PAGE_SIZE_MEDIUM && (
<NextPrevious
currentPage={currentTeamUserPage}
isNumberBased={Boolean(teamUsersSearchText)}
pageSize={PAGE_SIZE_MEDIUM}
paging={teamUserPagin}
pagingHandler={teamUserPaginHandler}
totalCount={teamUserPagin.total}
/>
)}
</Fragment>
</div>
)}
</>
)}
</>
);
};
/**
* Check for current team datasets and return the dataset cards
* @returns - dataset cards
@ -1315,7 +1164,21 @@ const TeamDetailsV1 = ({
</Row>
))}
{currentTab === TeamsPageTab.USERS && getUserCards()}
{currentTab === TeamsPageTab.USERS && (
<UserTab
currentPage={currentTeamUserPage}
currentTeam={currentTeam}
isLoading={isTeamMemberLoading}
paging={teamUserPagin}
permission={entityPermissions}
searchText={teamUsersSearchText}
users={currentTeamUsers}
onAddUser={handleAddUser}
onChangePaging={teamUserPaginHandler}
onRemoveUser={removeUserFromTeam}
onSearchUsers={handleTeamUsersSearchAction}
/>
)}
{currentTab === TeamsPageTab.ASSETS && getAssetDetailCards()}

View File

@ -0,0 +1,249 @@
/*
* 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 { PlusOutlined } from '@ant-design/icons';
import { Button, Col, Row, Space, Table, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { ReactComponent as ExportIcon } from 'assets/svg/ic-export.svg';
import { ReactComponent as IconRemove } from 'assets/svg/ic-remove.svg';
import ManageButton from 'components/common/entityPageInfo/ManageButton/ManageButton';
import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder';
import FilterTablePlaceHolder from 'components/common/error-with-placeholder/FilterTablePlaceHolder';
import { ManageButtonItemLabel } from 'components/common/ManageButtonContentItem/ManageButtonContentItem.component';
import NextPrevious from 'components/common/next-previous/NextPrevious';
import Searchbar from 'components/common/searchbar/Searchbar';
import { UserSelectableList } from 'components/common/UserSelectableList/UserSelectableList.component';
import { useEntityExportModalProvider } from 'components/Entity/EntityExportModalProvider/EntityExportModalProvider.component';
import Loader from 'components/Loader/Loader';
import ConfirmationModal from 'components/Modals/ConfirmationModal/ConfirmationModal';
import { commonUserDetailColumns } from 'components/Users/Users.util';
import { PAGE_SIZE_MEDIUM } from 'constants/constants';
import { ERROR_PLACEHOLDER_TYPE } from 'enums/common.enum';
import { User } from 'generated/entity/teams/user';
import { EntityReference } from 'generated/entity/type';
import { isEmpty, orderBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { exportUserOfTeam } from 'rest/teamsAPI';
import { getEntityName } from 'utils/EntityUtils';
import { UserTabProps } from './UserTab.interface';
export const UserTab = ({
users,
searchText,
isLoading,
permission,
currentTeam,
onSearchUsers,
onAddUser,
paging,
onChangePaging,
currentPage,
onRemoveUser,
}: UserTabProps) => {
const { t } = useTranslation();
const [deletingUser, setDeletingUser] = useState<EntityReference>();
const { showModal } = useEntityExportModalProvider();
const handleRemoveClick = (id: string) => {
const user = currentTeam.users?.find((u) => u.id === id);
setDeletingUser(user);
};
const columns: ColumnsType<User> = useMemo(() => {
return [
...commonUserDetailColumns(),
{
title: t('label.action-plural'),
dataIndex: 'actions',
key: 'actions',
width: 90,
render: (_, record) => (
<Space
align="center"
className="w-full justify-center remove-icon"
size={8}>
<Tooltip
placement="bottomRight"
title={
permission.EditAll
? t('label.remove')
: t('message.no-permission-for-action')
}>
<Button
data-testid="remove-user-btn"
disabled={!permission.EditAll}
icon={
<IconRemove height={16} name={t('label.remove')} width={16} />
}
type="text"
onClick={() => handleRemoveClick(record.id)}
/>
</Tooltip>
</Space>
),
},
];
}, [handleRemoveClick, permission]);
const sortedUser = useMemo(() => orderBy(users, ['name'], 'asc'), [users]);
const handleUserExportClick = useCallback(async () => {
if (currentTeam?.name) {
showModal({
name: currentTeam.name,
onExport: exportUserOfTeam,
});
}
}, [currentTeam, exportUserOfTeam]);
const IMPORT_EXPORT_MENU_ITEM = useMemo(
() => [
{
label: (
<ManageButtonItemLabel
description={t('message.export-entity-help', {
entity: t('label.user-lowercase'),
})}
icon={<ExportIcon width="18px" />}
id="export"
name={t('label.export')}
/>
),
onClick: handleUserExportClick,
key: 'export-button',
},
],
[handleUserExportClick]
);
const handleRemoveUser = () => {
if (deletingUser?.id) {
onRemoveUser(deletingUser.id).then(() => {
setDeletingUser(undefined);
});
}
};
if (isEmpty(users) && !searchText && isLoading <= 0) {
return (
<ErrorPlaceHolder
button={
<UserSelectableList
hasPermission
selectedUsers={currentTeam.users ?? []}
onUpdate={onAddUser}>
<Button
ghost
className="p-x-lg"
data-testid="add-new-user"
icon={<PlusOutlined />}
title={
permission.EditAll
? t('label.add-new-entity', { entity: t('label.user') })
: t('message.no-permission-for-action')
}
type="primary">
{t('label.add')}
</Button>
</UserSelectableList>
}
className="mt-0-important"
heading={t('label.user')}
permission={permission.EditAll}
type={ERROR_PLACEHOLDER_TYPE.ASSIGN}
/>
);
}
return (
<Row gutter={[16, 16]}>
<Col span={24}>
<Row justify="space-between">
<Col span={8}>
<Searchbar
removeMargin
placeholder={t('label.search-for-type', {
type: t('label.user-lowercase'),
})}
searchValue={searchText}
typingInterval={500}
onSearch={onSearchUsers}
/>
</Col>
<Col>
<Space>
{users.length > 0 && permission.EditAll && (
<UserSelectableList
hasPermission
selectedUsers={currentTeam.users ?? []}
onUpdate={onAddUser}>
<Button data-testid="add-new-user" type="primary">
{t('label.add-entity', { entity: t('label.user') })}
</Button>
</UserSelectableList>
)}
<ManageButton
canDelete={false}
entityName={currentTeam.name}
extraDropdownContent={IMPORT_EXPORT_MENU_ITEM}
/>
</Space>
</Col>
</Row>
</Col>
{isLoading > 0 ? (
<Loader />
) : (
<Col span={24}>
<Table
bordered
className="teams-list-table"
columns={columns}
dataSource={sortedUser}
locale={{
emptyText: <FilterTablePlaceHolder />,
}}
pagination={false}
rowKey="name"
size="small"
/>
{paging.total > PAGE_SIZE_MEDIUM && (
<NextPrevious
currentPage={currentPage}
isNumberBased={Boolean(searchText)}
pageSize={PAGE_SIZE_MEDIUM}
paging={paging}
pagingHandler={onChangePaging}
totalCount={paging.total}
/>
)}
</Col>
)}
<ConfirmationModal
bodyText={t('message.are-you-sure-want-to-text', {
text: t('label.remove-entity', {
entity: getEntityName(deletingUser),
}),
})}
cancelText={t('label.cancel')}
confirmText={t('label.confirm')}
header={t('label.removing-user')}
visible={Boolean(deletingUser)}
onCancel={() => setDeletingUser(undefined)}
onConfirm={handleRemoveUser}
/>
</Row>
);
};

View File

@ -0,0 +1,31 @@
/*
* 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 { OperationPermission } from 'components/PermissionProvider/PermissionProvider.interface';
import { Team } from 'generated/entity/teams/team';
import { User } from 'generated/entity/teams/user';
import { EntityReference } from 'generated/type/entityReference';
import { Paging } from 'generated/type/paging';
export interface UserTabProps {
users: User[];
searchText: string;
isLoading: number;
permission: OperationPermission;
currentTeam: Team;
onSearchUsers: (text: string) => void;
onAddUser: (data: EntityReference[]) => void;
paging: Paging;
onChangePaging: (cursorValue: string | number, activePage?: number) => void;
currentPage: number;
onRemoveUser: (id: string) => Promise<void>;
}

View File

@ -0,0 +1,156 @@
/*
* 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,
findByText,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { OperationPermission } from 'components/PermissionProvider/PermissionProvider.interface';
import { pagingObject } from 'constants/constants';
import { Team } from 'generated/entity/teams/team';
import { User } from 'generated/entity/teams/user';
import { MOCK_MARKETING_TEAM } from 'mocks/Teams.mock';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { UserTab } from './UserTab.component';
import { UserTabProps } from './UserTab.interface';
const props: UserTabProps = {
users: MOCK_MARKETING_TEAM.users as User[],
searchText: '',
isLoading: 0,
permission: {
EditAll: true,
} as OperationPermission,
currentTeam: MOCK_MARKETING_TEAM as Team,
onSearchUsers: jest.fn(),
onAddUser: jest.fn(),
paging: pagingObject,
onChangePaging: jest.fn(),
currentPage: 1,
onRemoveUser: jest.fn().mockResolvedValue('removed'),
};
jest.mock('components/common/error-with-placeholder/ErrorPlaceHolder', () => {
return jest.fn().mockImplementation(() => <div>ErrorPlaceHolder</div>);
});
jest.mock('components/common/next-previous/NextPrevious', () => {
return jest.fn().mockImplementation(() => <div>NextPrevious</div>);
});
jest.mock('components/common/searchbar/Searchbar', () => {
return jest.fn().mockImplementation(() => <div>Searchbar</div>);
});
jest.mock('components/Loader/Loader', () => {
return jest.fn().mockImplementation(() => <div>Loader</div>);
});
jest.mock('components/common/entityPageInfo/ManageButton/ManageButton', () => {
return jest.fn().mockImplementation(() => <div>ManageButton</div>);
});
jest.mock('components/Modals/ConfirmationModal/ConfirmationModal', () => {
return jest.fn().mockImplementation(({ onConfirm }) => (
<div data-testid="confirmation-modal">
<button onClick={onConfirm}>confirm</button>
</div>
));
});
jest.mock(
'components/common/UserSelectableList/UserSelectableList.component',
() => ({
UserSelectableList: jest
.fn()
.mockImplementation(({ children }) => (
<div data-testid="user-selectable-list">{children}</div>
)),
})
);
describe('UserTab', () => {
it('Component should render', async () => {
render(
<BrowserRouter>
<UserTab {...props} />
</BrowserRouter>
);
expect(await screen.findByRole('table')).toBeInTheDocument();
expect(
await screen.findByTestId('user-selectable-list')
).toBeInTheDocument();
expect(await screen.findByTestId('add-new-user')).toBeInTheDocument();
expect(await screen.findByText('Searchbar')).toBeInTheDocument();
expect(await screen.findByText('ManageButton')).toBeInTheDocument();
});
it('Error placeholder should visible if there is no data', async () => {
render(
<BrowserRouter>
<UserTab {...props} users={[]} />
</BrowserRouter>
);
expect(await screen.findByText('ErrorPlaceHolder')).toBeInTheDocument();
});
it('Loader should visible if data is loading', async () => {
render(
<BrowserRouter>
<UserTab {...props} isLoading={1} />
</BrowserRouter>
);
expect(await screen.findByText('Loader')).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(
await screen.findByTestId('user-selectable-list')
).toBeInTheDocument();
expect(await screen.findByTestId('add-new-user')).toBeInTheDocument();
expect(await screen.findByText('Searchbar')).toBeInTheDocument();
});
it('Pagination should visible if total value is greater then 15', async () => {
render(
<BrowserRouter>
<UserTab {...props} paging={{ total: 16 }} />
</BrowserRouter>
);
expect(await screen.findByText('NextPrevious')).toBeInTheDocument();
});
it('Remove user flow', async () => {
render(
<BrowserRouter>
<UserTab {...props} />
</BrowserRouter>
);
const removeBtn = await screen.findByTestId('remove-user-btn');
expect(removeBtn).toBeInTheDocument();
await act(async () => {
fireEvent.click(removeBtn);
});
const confirmationModal = await screen.findByTestId('confirmation-modal');
const confirmBtn = await findByText(confirmationModal, 'confirm');
expect(confirmationModal).toBeInTheDocument();
expect(confirmBtn).toBeInTheDocument();
await act(async () => {
fireEvent.click(confirmBtn);
});
expect(props.onRemoveUser).toHaveBeenCalledWith(props.users[0].id);
});
});

View File

@ -202,6 +202,58 @@ export const MOCK_TABLE_DATA = [
},
];
export const MOCK_MARKETING_TEAM = {
id: 'afa05b3f-bee4-4ead-8457-d82f0040faf8',
teamType: 'Group',
name: 'Marketing',
fullyQualifiedName: 'Marketing',
version: 0.1,
updatedAt: 1686117247164,
updatedBy: 'admin',
href: 'test',
parents: [
{
id: '7d82d6ca-9768-4a51-b4be-36c0f2add94c',
type: 'team',
name: 'Finance',
fullyQualifiedName: 'Finance',
deleted: false,
href: 'test',
},
],
users: [
{
id: '17c88b6f-8f21-40d3-afeb-30f5bdc2a537',
type: 'user',
name: 'aaron_warren5',
fullyQualifiedName: 'aaron_warren5',
displayName: 'Aaron Warren',
deleted: false,
href: 'test',
email: 'aaron_warren5@gmail.com',
},
],
childrenCount: 0,
owns: [],
isJoinable: true,
deleted: false,
defaultRoles: [],
inheritedRoles: [
{
id: '829d9442-5e38-401b-8f32-8970c1290360',
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: 'test',
},
],
policies: [],
};
export const MOCK_CSV_TEAM_DATA = {
rowData: [
[

View File

@ -438,7 +438,7 @@ const TeamsPage = () => {
patchTeamDetail(selectedTeam.id, jsonPatch)
.then((res) => {
if (res) {
fetchTeamByFqn(res.name);
fetchTeamByFqn(res.name, false);
} else {
throw t('server.unexpected-response');
}

View File

@ -130,6 +130,14 @@ export const exportTeam = async (teamName: string) => {
return response.data;
};
export const exportUserOfTeam = async (team: string) => {
const response = await APIClient.get<string>(`/users/export`, {
params: { team },
});
return response.data;
};
export const importTeam = async (
teamName: string,
data: string,