diff --git a/cypress/e2e/roles.cy.ts b/cypress/e2e/roles.cy.ts index 821b73f20..dc58e3339 100644 --- a/cypress/e2e/roles.cy.ts +++ b/cypress/e2e/roles.cy.ts @@ -50,7 +50,7 @@ describe('Roles page', () => { }).as('getRoles'); cy.visit('/iam/user-access/roles'); - cy.wait('@getRoles', { timeout: 10000 }); + cy.wait('@getRoles', { timeout: 15000 }); }); it('should display the Roles table and correct data', () => { diff --git a/cypress/e2e/users-and-user-groups.cy.ts b/cypress/e2e/users-and-user-groups.cy.ts index 2e0875f04..5d646534d 100644 --- a/cypress/e2e/users-and-user-groups.cy.ts +++ b/cypress/e2e/users-and-user-groups.cy.ts @@ -114,4 +114,10 @@ describe('Users and User Groups page', () => { }); }); }); + + it('should be able to open Add User Modal once an active user is selected', () => { + cy.get('[aria-label="Select row 0"]').click(); + cy.get('[data-ouia-component-id="iam-users-table-add-user-button"]').click(); + cy.get('[data-ouia-component-id="add-user-group-modal"]').should('be.visible'); + }); }); diff --git a/src/Messages.js b/src/Messages.js index aa3893b8c..c23c9f5b3 100644 --- a/src/Messages.js +++ b/src/Messages.js @@ -2112,6 +2112,67 @@ export default defineMessages({ description: 'Users and user groups description', defaultMessage: 'These are all of the users in your Red Hat organization. Create User Groups to define access across your workspaces.', }, + usersAndUserGroupsAdd: { + id: 'usersAndUserGroupsAdd', + description: 'Add label', + defaultMessage: 'Add', + }, + usersAndUserGroupsAddToGroup: { + id: 'usersAndUserGroupsAddToGroup', + description: 'Add to user group label', + defaultMessage: 'Add to user group', + }, + usersAndUserGroupsEditUserGroup: { + id: 'usersAndUserGroupsEditUserGroup', + description: 'Edit user group label', + defaultMessage: 'Edit user group', + }, + usersAndUserGroupsDeleteUserGroup: { + id: 'usersAndUserGroupsDeleteUserGroup', + description: 'Delete user group label', + defaultMessage: 'Delete user group', + }, + usersAndUserGroupsRemoveFromGroup: { + id: 'usersAndUserGroupsAddToGroup', + description: 'Remove from user group label', + defaultMessage: 'Remove from user group', + }, + usersAndUserGroupsCancel: { + id: 'usersAndUserGroupsCancel', + description: 'Cancel add user label', + defaultMessage: 'Cancel', + }, + usersAndUserGroupsNoDescription: { + id: 'usersAndUserGroupsNoDescription', + description: 'No description label', + defaultMessage: 'No description', + }, + usersAndUserGroupsActive: { + id: 'usersAndUserGroupsActive', + description: 'User is active label', + defaultMessage: 'Active', + }, + usersAndUserGroupsInactive: { + id: 'usersAndUserGroupsInactive', + description: 'User is inactive label', + defaultMessage: 'Inactive', + }, + usersAndUserGroupsYes: { + id: 'usersAndUserGroupsYes', + description: 'Yes is Org Admin label', + defaultMessage: 'Yes', + }, + usersAndUserGroupsNo: { + id: 'usersAndUserGroupsNo', + description: 'No isnt Org Admin label', + defaultMessage: 'No', + }, + usersAndUserGroupsAddUserDescription: { + id: 'usersAndUserGroupsAddUserDescription', + description: 'Description within add user to user group modal', + defaultMessage: + 'Select a user group to add {numUsers} {plural} to. These are all the user groups in your account. To manage user groups, go to user groups.', + }, assignedUserGroupsTooltipHeader: { id: 'assignedUserGroupsTooltipHeader', description: 'header for assigned user groups tooltip', diff --git a/src/redux/reducers/user-reducer.ts b/src/redux/reducers/user-reducer.ts index c75cc84ff..df99aa0ff 100644 --- a/src/redux/reducers/user-reducer.ts +++ b/src/redux/reducers/user-reducer.ts @@ -1,5 +1,6 @@ import { FETCH_USERS, UPDATE_USERS_FILTERS } from '../action-types'; import { defaultSettings, PaginationDefaultI } from '../../helpers/shared/pagination'; +import { UserProps } from '../../smart-components/user/user-table-helpers'; export interface User { email: string; @@ -24,7 +25,7 @@ export interface UserStore { meta: PaginationDefaultI; filters: UserFilters; pagination: PaginationDefaultI & { redirected?: boolean }; - data?: User[]; + data?: UserProps[]; }; } diff --git a/src/smart-components/access-management/AddUserGroupModal.tsx b/src/smart-components/access-management/AddUserGroupModal.tsx new file mode 100644 index 000000000..9198e1e5a --- /dev/null +++ b/src/smart-components/access-management/AddUserGroupModal.tsx @@ -0,0 +1,65 @@ +import { Button, Modal } from '@patternfly/react-core'; +import React from 'react'; +import UserGroupsTable from './UserGroupsTable'; +import { useDispatch } from 'react-redux'; +import { addMembersToGroup } from '../../redux/actions/group-actions'; +import { FormattedMessage, useIntl } from 'react-intl'; +import messages from '../../Messages'; + +interface AddUserGroupModalProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + selectedUsers: any[]; +} + +export const AddUserGroupModal: React.FunctionComponent = ({ isOpen, setIsOpen, selectedUsers }) => { + const [selectedGroups, setSelectedGroups] = React.useState([]); + const handleUserGroupsChange = (groups: any[]) => setSelectedGroups(groups); + const dispatch = useDispatch(); + const intl = useIntl(); + + const handleCloseModal = () => setIsOpen(false); + + const handleAddUsers = () => { + const selectedUsernames = selectedUsers.map((user) => ({ username: user.id })); + selectedGroups.forEach((group) => { + dispatch(addMembersToGroup(group.id, selectedUsernames)); + }); + setIsOpen(false); + }; + + return ( + + {intl.formatMessage(messages['usersAndUserGroupsAdd'])} + , + , + ]} + ouiaId="add-user-group-modal" + > + {text}, + numUsers: selectedUsers.length, + plural: selectedUsers.length > 1 ? 'users' : 'user', + }} + /> + + + ); +}; + +export default AddUserGroupModal; diff --git a/src/smart-components/access-management/UserGroupsTable.tsx b/src/smart-components/access-management/UserGroupsTable.tsx index 6eb024326..9fa83b095 100644 --- a/src/smart-components/access-management/UserGroupsTable.tsx +++ b/src/smart-components/access-management/UserGroupsTable.tsx @@ -12,14 +12,11 @@ import { RBACStore } from '../../redux/store'; import { useSearchParams } from 'react-router-dom'; import { fetchGroups } from '../../redux/actions/group-actions'; import { formatDistanceToNow } from 'date-fns'; +import { useIntl } from 'react-intl'; +import messages from '../../Messages'; const COLUMNS: string[] = ['User group name', 'Description', 'Users', 'Service accounts', 'Roles', 'Workspaces', 'Last modified']; -const ROW_ACTIONS = [ - { title: 'Edit user group', onClick: () => console.log('EDIT USER GROUP') }, - { title: 'Delete user group', onClick: () => console.log('DELETE USER GROUP') }, -]; - const PER_PAGE_OPTIONS = [ { title: '5', value: 5 }, { title: '10', value: 10 }, @@ -28,27 +25,62 @@ const PER_PAGE_OPTIONS = [ { title: '100', value: 100 }, ]; -const OUIA_ID = 'iam-user-groups-table'; - -const UserGroupsTable: React.FunctionComponent = () => { +interface UserGroupsTableProps { + defaultPerPage?: number; + useUrlParams?: boolean; + enableActions?: boolean; + ouiaId?: string; + onChange?: (selectedGroups: any[]) => void; +} + +const UserGroupsTable: React.FunctionComponent = ({ + defaultPerPage = 20, + useUrlParams = true, + enableActions = true, + ouiaId = 'iam-user-groups-table', + onChange, +}) => { const dispatch = useDispatch(); + const intl = useIntl(); + + const rowActions = [ + { title: intl.formatMessage(messages['usersAndUserGroupsEditUserGroup']), onClick: () => console.log('EDIT USER GROUP') }, + { title: intl.formatMessage(messages['usersAndUserGroupsDeleteUserGroup']), onClick: () => console.log('DELETE USER GROUP') }, + ]; const { groups, totalCount } = useSelector((state: RBACStore) => ({ groups: state.groupReducer?.groups?.data || [], totalCount: state.groupReducer?.groups?.meta.count || 0, })); - const [searchParams, setSearchParams] = useSearchParams(); - const pagination = useDataViewPagination({ perPage: 20, searchParams, setSearchParams }); + let pagination; + + if (useUrlParams) { + const [searchParams, setSearchParams] = useSearchParams(); + pagination = useDataViewPagination({ + perPage: defaultPerPage, + searchParams: searchParams, + setSearchParams: setSearchParams, + }); + } else { + const [perPage, setPerPage] = React.useState(defaultPerPage); + const [page, setPage] = React.useState(1); + pagination = { + page, + perPage, + onSetPage: (_e: any, page: number) => setPage(page), + onPerPageSelect: (_e: any, perPage: number) => setPerPage(perPage), + }; + } const { page, perPage, onSetPage, onPerPageSelect } = pagination; - const selection = useDataViewSelection({ matchOption: (a, b) => a[0] === b[0] }); + const selection = useDataViewSelection({ matchOption: (a, b) => a.id === b.id }); const { selected, onSelect, isSelected } = selection; const fetchData = useCallback( (apiProps: { count: number; limit: number; offset: number; orderBy: string }) => { const { count, limit, offset, orderBy } = apiProps; - dispatch(fetchGroups({ ...mappedProps({ count, limit, offset, orderBy }), usesMetaInURL: true })); + dispatch(fetchGroups({ ...mappedProps({ count, limit, offset, orderBy }), usesMetaInURL: true, system: false })); }, [dispatch] ); @@ -62,6 +94,10 @@ const UserGroupsTable: React.FunctionComponent = () => { }); }, [fetchData, page, perPage]); + useEffect(() => { + onChange?.(selected); + }, [selected]); + const handleBulkSelect = (value: BulkSelectValue) => { if (value === BulkSelectValue.none) { onSelect(false); @@ -72,25 +108,28 @@ const UserGroupsTable: React.FunctionComponent = () => { } }; - const rows = groups.map((group: any) => [ - group.name, - group.description ? ( - - {group.description.length > 23 ? group.description.slice(0, 20) + '...' : group.description} - - ) : ( -
No description
- ), - group.principalCount, - group.serviceAccounts || '?', // not currently in API - group.roleCount, - group.workspaces || '?', // not currently in API - formatDistanceToNow(new Date(group.modified), { addSuffix: true }), - { - cell: , - props: { isActionCell: true }, - }, - ]); + const rows = groups.map((group: any) => ({ + id: group.uuid, + row: [ + group.name, + group.description ? ( + + {group.description.length > 23 ? group.description.slice(0, 20) + '...' : group.description} + + ) : ( +
{intl.formatMessage(messages['usersAndUserGroupsNoDescription'])}
+ ), + group.principalCount, + group.serviceAccounts || '?', // not currently in API + group.roleCount, + group.workspaces || '?', // not currently in API + formatDistanceToNow(new Date(group.modified), { addSuffix: true }), + enableActions && { + cell: , + props: { isActionCell: true }, + }, + ], + })); const pageSelected = rows.length > 0 && rows.every(isSelected); const pagePartiallySelected = !pageSelected && rows.some(isSelected); @@ -107,9 +146,9 @@ const UserGroupsTable: React.FunctionComponent = () => { ); return ( - + { } pagination={React.cloneElement(paginationComponent, { isCompact: true })} /> - - + + ); }; diff --git a/src/smart-components/access-management/UsersTable.tsx b/src/smart-components/access-management/UsersTable.tsx index 51de4cf1a..413ed74eb 100644 --- a/src/smart-components/access-management/UsersTable.tsx +++ b/src/smart-components/access-management/UsersTable.tsx @@ -5,7 +5,7 @@ import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups/ import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView'; import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; import { DataViewTable } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; -import { ButtonVariant, Pagination } from '@patternfly/react-core'; +import { Button, Pagination, ButtonVariant } from '@patternfly/react-core'; import { ActionsColumn } from '@patternfly/react-table'; import { fetchUsers } from '../../redux/actions/user-actions'; import { mappedProps } from '../../helpers/shared/helpers'; @@ -15,6 +15,7 @@ import { useIntl } from 'react-intl'; import messages from '../../Messages'; import { useSearchParams } from 'react-router-dom'; import { WarningModal } from '@patternfly/react-component-groups'; +import { UserProps } from '../user/user-table-helpers'; const COLUMNS: string[] = ['Username', 'Email', 'First name', 'Last name', 'Status', 'Org admin']; @@ -28,11 +29,14 @@ const PER_PAGE_OPTIONS = [ const OUIA_ID = 'iam-users-table'; -const UsersTable: React.FunctionComponent = () => { +interface UsersTableProps { + onAddUserClick: (selected: any[]) => void; +} + +const UsersTable: React.FunctionComponent = ({ onAddUserClick }) => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [currentUser, setCurrentUser] = useState(); const dispatch = useDispatch(); - const intl = useIntl(); const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent, user: User) => { @@ -49,7 +53,7 @@ const UsersTable: React.FunctionComponent = () => { const pagination = useDataViewPagination({ perPage: 20, searchParams, setSearchParams }); const { page, perPage, onSetPage, onPerPageSelect } = pagination; - const selection = useDataViewSelection({ matchOption: (a, b) => a[0] === b[0] }); + const selection = useDataViewSelection({ matchOption: (a, b) => a.id === b.id }); const { selected, onSelect, isSelected } = selection; const fetchData = useCallback( @@ -80,30 +84,37 @@ const UsersTable: React.FunctionComponent = () => { }; const rows = useMemo(() => { - return users.map((user: User) => [ - user.username, - user.email, - user.first_name, - user.last_name, - user.is_active ? 'Active' : 'Inactive', - user.is_org_admin ? 'Yes' : 'No', - { - cell: ( - console.log('ADD TO USER GROUP') }, - { - title: intl.formatMessage(messages.removeFromUserGroup), - onClick: (event: KeyboardEvent | React.MouseEvent, rowId: number, rowData: any) => handleModalToggle(event, rowData), - }, - ]} - rowData={user} - /> - ), - props: { isActionCell: true }, - }, - ]); - }, [users]); + return users.map((user: UserProps) => ({ + id: user.username, + is_active: user.is_active, + row: [ + user.username, + user.email, + user.first_name, + user.last_name, + user.is_active ? intl.formatMessage(messages['usersAndUserGroupsActive']) : intl.formatMessage(messages['usersAndUserGroupsInactive']), + user.is_org_admin ? intl.formatMessage(messages['usersAndUserGroupsYes']) : intl.formatMessage(messages['usersAndUserGroupsNo']), + { + cell: ( + onAddUserClick([user]), + }, + { + title: intl.formatMessage(messages['usersAndUserGroupsRemoveFromGroup']), + onClick: (event: KeyboardEvent | React.MouseEvent, rowId: number, rowData: any) => handleModalToggle(event, rowData), + }, + ]} + rowData={user} + /> + ), + props: { isActionCell: true }, + }, + ], + })); + }, [users, intl, onAddUserClick, handleModalToggle]); const pageSelected = rows.length > 0 && rows.every(isSelected); const pagePartiallySelected = !pageSelected && rows.some(isSelected); @@ -138,7 +149,7 @@ const UsersTable: React.FunctionComponent = () => { {`${currentUser?.username} ${intl.formatMessage(messages.deleteUserModalBody)}`} )} - + !row.is_active }}> { /> } pagination={React.cloneElement(paginationComponent, { isCompact: true })} + actions={ + + } /> diff --git a/src/smart-components/access-management/users-and-user-groups.tsx b/src/smart-components/access-management/users-and-user-groups.tsx index fb22cfbb5..663cd978c 100644 --- a/src/smart-components/access-management/users-and-user-groups.tsx +++ b/src/smart-components/access-management/users-and-user-groups.tsx @@ -6,12 +6,15 @@ import Messages from '../../Messages'; import UsersTable from './UsersTable'; import UserGroupsTable from './UserGroupsTable'; import { useLocation, useNavigate } from 'react-router-dom'; +import AddUserGroupModal from './AddUserGroupModal'; const TAB_NAMES = ['users', 'user-groups']; const UsersAndUserGroups: React.FunctionComponent = () => { const intl = useIntl(); const [activeTabKey, setActiveTabKey] = React.useState(0); + const [isAddUserGroupModalOpen, setIsAddUserGroupModalOpen] = React.useState(false); + const [selectedUsers, setSelectedUsers] = React.useState([]); const usersRef = React.createRef(); const groupsRef = React.createRef(); @@ -31,6 +34,13 @@ const UsersAndUserGroups: React.FunctionComponent = () => { updateURL(TAB_NAMES[activeTab]); }; + const handleOpenAddUserModal = (selected: any[]) => { + if (selected.length > 0) { + setSelectedUsers(selected); + setIsAddUserGroupModalOpen(true); + } + }; + useEffect(() => { const params = new URLSearchParams(location.search); const tabKey = params.get('activeTab'); @@ -39,6 +49,7 @@ const UsersAndUserGroups: React.FunctionComponent = () => { return ( + { - - - - + {activeTabKey === 0 && ( + + + + )} + {activeTabKey === 1 && ( + + + + )} );