diff --git a/cypress/e2e/users-and-user-groups.cy.ts b/cypress/e2e/users-and-user-groups.cy.ts index 5d646534d..91eabe5cb 100644 --- a/cypress/e2e/users-and-user-groups.cy.ts +++ b/cypress/e2e/users-and-user-groups.cy.ts @@ -120,4 +120,12 @@ describe('Users and User Groups page', () => { 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'); }); + + it('can view user details when a user is clicked', () => { + cy.get('[data-ouia-component-id="iam-users-table-table-tr-0"]').click(); + cy.get('[data-ouia-component-id="user-details-drawer"]').should('be.visible'); + cy.get('[data-ouia-component-id="user-details-drawer"]').contains(mockUsers.data[0].first_name).should('exist'); + cy.get('[data-ouia-component-id="user-details-drawer"]').contains(mockUsers.data[0].last_name).should('exist'); + cy.get('[data-ouia-component-id="user-details-drawer"]').contains(mockUsers.data[0].email).should('exist'); + }); }); diff --git a/src/Messages.js b/src/Messages.js index c23c9f5b3..229171b79 100644 --- a/src/Messages.js +++ b/src/Messages.js @@ -947,6 +947,11 @@ export default defineMessages({ description: 'Overview Hero third list item', defaultMessage: `Assign users to these groups, allowing them to inherit the permissions associated with their group's roles`, }, + workspace: { + id: 'workspace', + description: 'Workspace singular label', + defaultMessage: 'Workspace', + }, workspaces: { id: 'workspaces', description: 'Workspaces heading', @@ -2173,6 +2178,17 @@ export default defineMessages({ 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.', }, + assignedRoles: { + id: 'assignedRoles', + description: 'User details assigned roles label', + defaultMessage: 'Assigned roles', + }, + assignedRolesDescription: { + id: 'assignedRolesDescription', + description: 'User details roles info popover description', + defaultMessage: + 'User groups are granted roles that contain a set of permissions. Roles are limited to the workspace in which they were assigned.', + }, 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 df99aa0ff..c75cc84ff 100644 --- a/src/redux/reducers/user-reducer.ts +++ b/src/redux/reducers/user-reducer.ts @@ -1,6 +1,5 @@ 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; @@ -25,7 +24,7 @@ export interface UserStore { meta: PaginationDefaultI; filters: UserFilters; pagination: PaginationDefaultI & { redirected?: boolean }; - data?: UserProps[]; + data?: User[]; }; } diff --git a/src/smart-components/access-management/UserDetailsDrawer.tsx b/src/smart-components/access-management/UserDetailsDrawer.tsx new file mode 100644 index 000000000..419a84136 --- /dev/null +++ b/src/smart-components/access-management/UserDetailsDrawer.tsx @@ -0,0 +1,118 @@ +import { + Drawer, + DrawerActions, + DrawerCloseButton, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelContent, + Icon, + Popover, + Tab, + TabTitleText, + Tabs, + Text, + TextContent, + Title, +} from '@patternfly/react-core'; +import React, { useEffect } from 'react'; +import { User } from '../../redux/reducers/user-reducer'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { useIntl } from 'react-intl'; +import messages from '../../Messages'; +import UserDetailsGroupsView from './UserDetailsGroupsView'; +import UserDetailsRolesView from './UserDetailsRolesView'; +import { EventTypes, useDataViewEventsContext } from '@patternfly/react-data-view'; + +interface UserDetailsProps { + focusedUser?: User; + drawerRef: React.RefObject; + onClose: () => void; + ouiaId: string; +} + +const UserDetailsDrawerContent: React.FunctionComponent = ({ focusedUser, drawerRef, onClose, ouiaId }) => { + const [activeTabKey, setActiveTabKey] = React.useState(0); + const intl = useIntl(); + + return ( + + + + <span tabIndex={focusedUser ? 0 : -1} ref={drawerRef}>{`${focusedUser?.first_name} ${focusedUser?.last_name}`}</span> + + + {focusedUser?.email} + + + + + + setActiveTabKey(tabIndex)}> + + {focusedUser && } + + + {intl.formatMessage(messages.assignedRoles)} + + + + + + + } + > + {focusedUser && } + + + + ); +}; + +interface DetailDrawerProps { + focusedUser?: User; + setFocusedUser: (user: User | undefined) => void; + children: React.ReactNode; + ouiaId: string; +} + +const UserDetailsDrawer: React.FunctionComponent = ({ focusedUser, setFocusedUser, children, ouiaId }) => { + const drawerRef = React.useRef(null); + const context = useDataViewEventsContext(); + + useEffect(() => { + const unsubscribe = context.subscribe(EventTypes.rowClick, (user: User | undefined) => { + setFocusedUser(user); + drawerRef.current?.focus(); + }); + + return () => unsubscribe(); + }, [drawerRef]); + + return ( + + setFocusedUser(undefined)} + /> + } + > + {children} + + + ); +}; + +export default UserDetailsDrawer; diff --git a/src/smart-components/access-management/UserDetailsGroupsView.tsx b/src/smart-components/access-management/UserDetailsGroupsView.tsx new file mode 100644 index 000000000..5051ad2e6 --- /dev/null +++ b/src/smart-components/access-management/UserDetailsGroupsView.tsx @@ -0,0 +1,43 @@ +import { DataView, DataViewTable } from '@patternfly/react-data-view'; +import React, { useCallback, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import { mappedProps } from '../../helpers/shared/helpers'; +import { fetchGroups } from '../../redux/actions/group-actions'; +import { RBACStore } from '../../redux/store'; +import messages from '../../Messages'; + +interface UserGroupsViewProps { + userId: string; + ouiaId: string; +} + +const UserDetailsGroupsView: React.FunctionComponent = ({ userId, ouiaId }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + const columns: string[] = [intl.formatMessage(messages.userGroup), intl.formatMessage(messages.users)]; + + const groups = useSelector((state: RBACStore) => state.groupReducer?.groups?.data || []); + + const fetchData = useCallback(() => { + dispatch(fetchGroups({ ...mappedProps({ username: userId }), usesMetaInURL: true, system: false })); + }, [dispatch, userId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const rows = groups.map((group: any) => ({ + row: [group.name, group.principalCount || '?'], // TODO: update once API provides principalCount [RHCLOUD-35963] + })); + + return ( +
+ + + +
+ ); +}; + +export default UserDetailsGroupsView; diff --git a/src/smart-components/access-management/UserDetailsRolesView.tsx b/src/smart-components/access-management/UserDetailsRolesView.tsx new file mode 100644 index 000000000..6ca781cd4 --- /dev/null +++ b/src/smart-components/access-management/UserDetailsRolesView.tsx @@ -0,0 +1,47 @@ +import { DataView, DataViewTable } from '@patternfly/react-data-view'; +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RBACStore } from '../../redux/store'; +import messages from '../../Messages'; +import { useIntl } from 'react-intl'; +import { fetchRoles } from '../../redux/actions/role-actions'; +import { mappedProps } from '../../helpers/shared/helpers'; + +interface UserRolesViewProps { + userId: string; + ouiaId: string; +} + +const UserDetailsRolesView: React.FunctionComponent = ({ userId, ouiaId }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + const USER_ROLES_COLUMNS: string[] = [ + intl.formatMessage(messages.roles), + intl.formatMessage(messages.userGroup), + intl.formatMessage(messages.workspace), + ]; + + const roles = useSelector((state: RBACStore) => state.roleReducer?.roles?.data || []); + + const fetchData = useCallback(() => { + dispatch(fetchRoles({ ...mappedProps({ username: userId }), usesMetaInURL: true, system: false })); + }, [dispatch, userId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const rows = roles.map((role: any) => ({ + row: [role.name, role.display_name, '?'], // TODO: Update once API provides workspace data + })); + + return ( +
+ + + +
+ ); +}; + +export default UserDetailsRolesView; diff --git a/src/smart-components/access-management/UsersTable.tsx b/src/smart-components/access-management/UsersTable.tsx index 413ed74eb..6fd7c80ca 100644 --- a/src/smart-components/access-management/UsersTable.tsx +++ b/src/smart-components/access-management/UsersTable.tsx @@ -15,7 +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'; +import { EventTypes, useDataViewEventsContext } from '@patternfly/react-data-view'; const COLUMNS: string[] = ['Username', 'Email', 'First name', 'Last name', 'Status', 'Org admin']; @@ -30,14 +30,16 @@ const PER_PAGE_OPTIONS = [ const OUIA_ID = 'iam-users-table'; interface UsersTableProps { - onAddUserClick: (selected: any[]) => void; + onAddUserClick: (selected: User[]) => void; + focusedUser?: User; } -const UsersTable: React.FunctionComponent = ({ onAddUserClick }) => { +const UsersTable: React.FunctionComponent = ({ onAddUserClick, focusedUser }) => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [currentUser, setCurrentUser] = useState(); const dispatch = useDispatch(); const intl = useIntl(); + const { trigger } = useDataViewEventsContext(); const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent, user: User) => { setCurrentUser(user); @@ -84,7 +86,11 @@ const UsersTable: React.FunctionComponent = ({ onAddUserClick } }; const rows = useMemo(() => { - return users.map((user: UserProps) => ({ + const handleRowClick = (event: any, user: User | undefined) => { + (event.target.matches('td') || event.target.matches('tr')) && trigger(EventTypes.rowClick, user); + }; + + return users.map((user: User) => ({ id: user.username, is_active: user.is_active, row: [ @@ -113,8 +119,13 @@ const UsersTable: React.FunctionComponent = ({ onAddUserClick } props: { isActionCell: true }, }, ], + props: { + isClickable: Boolean(user.is_active), + onRowClick: (event: any) => user.is_active && handleRowClick(event, focusedUser?.username === user.username ? undefined : user), + isRowSelected: focusedUser?.username === user.username, + }, })); - }, [users, intl, onAddUserClick, handleModalToggle]); + }, [users, intl, onAddUserClick, handleModalToggle, trigger, focusedUser?.username]); const pageSelected = rows.length > 0 && rows.every(isSelected); const pagePartiallySelected = !pageSelected && rows.some(isSelected); 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 663cd978c..34b8ad8b1 100644 --- a/src/smart-components/access-management/users-and-user-groups.tsx +++ b/src/smart-components/access-management/users-and-user-groups.tsx @@ -7,6 +7,9 @@ import UsersTable from './UsersTable'; import UserGroupsTable from './UserGroupsTable'; import { useLocation, useNavigate } from 'react-router-dom'; import AddUserGroupModal from './AddUserGroupModal'; +import { User } from '../../redux/reducers/user-reducer'; +import UserDetailsDrawer from './UserDetailsDrawer'; +import { DataViewEventsProvider } from '@patternfly/react-data-view'; const TAB_NAMES = ['users', 'user-groups']; @@ -14,8 +17,8 @@ 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 [selectedUsers, setSelectedUsers] = React.useState([]); + const [focusedUser, setFocusedUser] = React.useState(undefined); const usersRef = React.createRef(); const groupsRef = React.createRef(); @@ -34,7 +37,7 @@ const UsersAndUserGroups: React.FunctionComponent = () => { updateURL(TAB_NAMES[activeTab]); }; - const handleOpenAddUserModal = (selected: any[]) => { + const handleOpenAddUserModal = (selected: User[]) => { if (selected.length > 0) { setSelectedUsers(selected); setIsAddUserGroupModalOpen(true); @@ -73,11 +76,15 @@ const UsersAndUserGroups: React.FunctionComponent = () => { /> - + {activeTabKey === 0 && ( - - - + + + + + + + )} {activeTabKey === 1 && (