diff --git a/cypress/e2e/users-and-user-groups.cy.ts b/cypress/e2e/users-and-user-groups.cy.ts index 2b78c5e7c..8435118ce 100644 --- a/cypress/e2e/users-and-user-groups.cy.ts +++ b/cypress/e2e/users-and-user-groups.cy.ts @@ -149,4 +149,12 @@ describe('Users and User Groups page', () => { cy.get('[data-ouia-component-id="iam-user-groups-table-actions-dropdown-action-0"]').click(); cy.get('[data-ouia-component-id="add-group-wizard"]').should('exist'); }); + + it('should be able to open Edit User Group page from row actions', () => { + cy.get('[data-ouia-component-id="user-groups-tab-button"]').click(); + cy.get('[data-ouia-component-id^="iam-user-groups-table-table-td-0-7"]').click(); + cy.get('[data-ouia-component-id^="iam-user-groups-table-table-td-0-7"] button').contains('Edit user group').click(); + cy.url().should('include', '/iam/access-management/users-and-user-groups/edit-group'); + cy.get('[data-ouia-component-id="edit-user-group-form"]').should('be.visible'); + }); }); diff --git a/src/Routing.tsx b/src/Routing.tsx index 6fa19413f..fae7f63b9 100644 --- a/src/Routing.tsx +++ b/src/Routing.tsx @@ -45,6 +45,7 @@ const RemoveServiceAccountFromGroup = lazy(() => import('./smart-components/grou const QuickstartsTest = lazy(() => import('./smart-components/quickstarts/quickstarts-test')); const UsersAndUserGroups = lazy(() => import('./smart-components/access-management/users-and-user-groups')); +const EditUserGroup = lazy(() => import('./smart-components/access-management/EditUserGroup')); const getRoutes = ({ enableServiceAccounts, isITLess, isWorkspacesFlag, isCommonAuthModel }: Record) => [ { @@ -57,6 +58,10 @@ const getRoutes = ({ enableServiceAccounts, isITLess, isWorkspacesFlag, isCommon }, ], }, + { + path: pathnames['users-and-user-groups-edit-group'].path, + element: EditUserGroup, + }, { path: pathnames.overview.path, element: isWorkspacesFlag ? WorkspacesOverview : Overview, diff --git a/src/redux/reducers/group-reducer.ts b/src/redux/reducers/group-reducer.ts index 118fdfe72..2ff6d0d44 100644 --- a/src/redux/reducers/group-reducer.ts +++ b/src/redux/reducers/group-reducer.ts @@ -36,7 +36,7 @@ export interface GroupStore { filters: any; pagination: { count: number }; }; - selectedGroup: { + selectedGroup: Group & { addRoles: any; members: { meta: PaginationDefaultI; data?: any[] }; serviceAccounts: { meta: PaginationDefaultI; data?: any[] }; diff --git a/src/smart-components/access-management/EditUserGroup.tsx b/src/smart-components/access-management/EditUserGroup.tsx new file mode 100644 index 000000000..16128a27e --- /dev/null +++ b/src/smart-components/access-management/EditUserGroup.tsx @@ -0,0 +1,114 @@ +import ContentHeader from '@patternfly/react-component-groups/dist/esm/ContentHeader'; +import { PageSection, PageSectionVariants } from '@patternfly/react-core'; +import React, { useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import Messages from '../../Messages'; +import { FormRenderer, componentTypes, validatorTypes } from '@data-driven-forms/react-form-renderer'; +import componentMapper from '@data-driven-forms/pf4-component-mapper/component-mapper'; +import { FormTemplate } from '@data-driven-forms/pf4-component-mapper'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchGroup, fetchGroups, updateGroup } from '../../redux/actions/group-actions'; +import { RBACStore } from '../../redux/store'; +import { useNavigate, useParams } from 'react-router-dom'; +import { EditGroupUsersAndServiceAccounts } from './EditUserGroupUsersAndServiceAccounts'; + +export const EditUserGroup: React.FunctionComponent = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const params = useParams(); + const groupId = params.groupId; + const navigate = useNavigate(); + + const group = useSelector((state: RBACStore) => state.groupReducer?.selectedGroup); + const allGroups = useSelector((state: RBACStore) => state.groupReducer?.groups?.data || []); + + useEffect(() => { + dispatch(fetchGroups({ limit: 1000, offset: 0, orderBy: 'name', usesMetaInURL: true })); + if (groupId) { + dispatch(fetchGroup(groupId)); + } + }, [dispatch, groupId]); + + const schema = { + fields: [ + { + name: 'name', + label: intl.formatMessage(Messages.name), + component: componentTypes.TEXT_FIELD, + validate: [ + { type: validatorTypes.REQUIRED }, + (value: string) => { + if (value === group?.name) { + return undefined; + } + + const isDuplicate = allGroups.some( + (existingGroup) => existingGroup.name.toLowerCase() === value?.toLowerCase() && existingGroup.uuid !== groupId + ); + + return isDuplicate ? intl.formatMessage(Messages.groupNameTakenTitle) : undefined; + }, + ], + initialValue: group?.name, + }, + { + name: 'description', + label: intl.formatMessage(Messages.description), + component: componentTypes.TEXTAREA, + initialValue: group?.description, + }, + { + name: 'users-and-service-accounts', + component: 'users-and-service-accounts', + groupId: groupId, + }, + ], + }; + + const returnToPreviousPage = () => { + navigate(-1); + }; + + const handleSubmit = async (values: Record) => { + if (values.name !== group?.name || values.description !== group?.description) { + dispatch(updateGroup({ uuid: groupId, name: values.name, description: values.description })); + console.log(`Dispatched update group with name: ${values.name} and description: ${values.description}`); + } + if (values['users-and-service-accounts']) { + const { users, serviceAccounts } = values['users-and-service-accounts']; + if (users.added.length > 0) { + console.log(`Users added: ${users.added}`); + } + if (users.removed.length > 0) { + console.log(`Users removed: ${users.removed}`); + } + if (serviceAccounts.added.length > 0) { + console.log(`Service accounts added: ${serviceAccounts.added}`); + } + if (serviceAccounts.removed.length > 0) { + console.log(`Service accounts removed: ${serviceAccounts.removed}`); + } + returnToPreviousPage(); + } + }; + + return ( + + + + + + + ); +}; + +export default EditUserGroup; diff --git a/src/smart-components/access-management/EditUserGroupServiceAccounts.tsx b/src/smart-components/access-management/EditUserGroupServiceAccounts.tsx new file mode 100644 index 000000000..36b4a7883 --- /dev/null +++ b/src/smart-components/access-management/EditUserGroupServiceAccounts.tsx @@ -0,0 +1,178 @@ +import { Pagination } from '@patternfly/react-core'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { DataView, DataViewTable, DataViewToolbar, useDataViewPagination, useDataViewSelection } from '@patternfly/react-data-view'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchServiceAccountsForGroup } from '../../redux/actions/group-actions'; +import { RBACStore } from '../../redux/store'; +import { mappedProps } from '../../helpers/shared/helpers'; +import { fetchServiceAccounts } from '../../redux/actions/service-account-actions'; +import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; +import { ServiceAccountsState } from '../../redux/reducers/service-account-reducer'; +import { LAST_PAGE, ServiceAccount } from '../../helpers/service-account/service-account-helper'; +import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups'; +import DateFormat from '@redhat-cloud-services/frontend-components/DateFormat'; +import { Diff } from './EditUserGroupUsersAndServiceAccounts'; + +interface EditGroupServiceAccountsTableProps { + groupId?: string; + onChange: (serviceAccounts: Diff) => void; +} + +const PER_PAGE_OPTIONS = [ + { title: '5', value: 5 }, + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + { title: '100', value: 100 }, +]; + +const reducer = ({ serviceAccountReducer }: { serviceAccountReducer: ServiceAccountsState }) => ({ + serviceAccounts: serviceAccountReducer.serviceAccounts, + status: serviceAccountReducer.status, + isLoading: serviceAccountReducer.isLoading, + limit: serviceAccountReducer.limit, + offset: serviceAccountReducer.offset, +}); + +const EditGroupServiceAccountsTable: React.FunctionComponent = ({ groupId, onChange }) => { + const dispatch = useDispatch(); + const pagination = useDataViewPagination({ perPage: 20 }); + const { page, perPage, onSetPage, onPerPageSelect } = pagination; + const { auth, getEnvironmentDetails } = useChrome(); + const initialServiceAccountIds = useRef([]); + + const selection = useDataViewSelection({ + matchOption: (a, b) => a.id === b.id, + }); + const { onSelect, selected } = selection; + + useEffect(() => { + return () => { + onSelect(false); + initialServiceAccountIds.current = []; + }; + }, []); + + const { serviceAccounts, status } = useSelector(reducer); + const calculateTotalCount = () => { + if (!serviceAccounts) return 0; + const currentCount = (page - 1) * perPage + serviceAccounts.length; + return status === LAST_PAGE ? currentCount : currentCount + 1; + }; + const totalCount = calculateTotalCount(); + + const { groupServiceAccounts: groupServiceAccounts } = useSelector((state: RBACStore) => ({ + groupServiceAccounts: state.groupReducer?.selectedGroup?.serviceAccounts?.data || [], + })); + + const fetchData = useCallback( + async (apiProps: { count: number; limit: number; offset: number; orderBy: string }) => { + if (groupId) { + const { count, limit, offset, orderBy } = apiProps; + const env = getEnvironmentDetails(); + const token = await auth.getToken(); + dispatch(fetchServiceAccounts({ ...mappedProps({ count, limit, offset, orderBy, token, sso: env?.sso }) })); + dispatch(fetchServiceAccountsForGroup(groupId, {})); + } + }, + [dispatch, groupId] + ); + + useEffect(() => { + fetchData({ + limit: perPage, + offset: (page - 1) * perPage, + orderBy: 'username', + count: totalCount || 0, + }); + }, [fetchData, page, perPage]); + + const processedServiceAccounts = serviceAccounts ? serviceAccounts.slice(0, perPage) : []; + const rows = useMemo( + () => + processedServiceAccounts.map((account: ServiceAccount) => ({ + id: account.uuid, + row: [ + account.name, + account.description, + account.clientId, + account.createdBy, + , + ], + })), + [processedServiceAccounts, groupId] + ); + + useEffect(() => { + // on mount, select the accounts that are in the current group + onSelect(false); + initialServiceAccountIds.current = []; + const initialSelectedServiceAccounts = groupServiceAccounts.map((account) => ({ id: account.clientId })); + onSelect(true, initialSelectedServiceAccounts); + initialServiceAccountIds.current = initialSelectedServiceAccounts.map((account) => account.id); + }, [groupServiceAccounts]); + + useEffect(() => { + const selectedServiceAccountIds = selection.selected.map((account) => account.id); + const added = selectedServiceAccountIds.filter((id) => !initialServiceAccountIds.current.includes(id)); + const removed = initialServiceAccountIds.current.filter((id) => !selectedServiceAccountIds.includes(id)); + onChange({ added, removed }); + }, [selection.selected]); + + const handleBulkSelect = (value: BulkSelectValue) => { + if (value === BulkSelectValue.none) { + onSelect(false); + } else if (value === BulkSelectValue.page) { + onSelect(true, rows); + } else if (value === BulkSelectValue.nonePage) { + onSelect(false, rows); + } + }; + + const pageSelected = rows.length > 0 && rows.every((row) => selection.isSelected(row)); + const pagePartiallySelected = !pageSelected && rows.some((row) => selection.isSelected(row)); + + return ( + + { + const firstIndex = (page - 1) * perPage + 1; + const lastIndex = Math.min(page * perPage, totalCount); + const totalNumber = status === LAST_PAGE ? (page - 1) * perPage + serviceAccounts.length : 'many'; + return ( + + + {firstIndex} - {lastIndex} + {' '} + of {totalNumber} + + ); + }} + /> + } + bulkSelect={ + + } + /> + + + ); +}; + +export default EditGroupServiceAccountsTable; diff --git a/src/smart-components/access-management/EditUserGroupUsers.tsx b/src/smart-components/access-management/EditUserGroupUsers.tsx new file mode 100644 index 000000000..0d4eace61 --- /dev/null +++ b/src/smart-components/access-management/EditUserGroupUsers.tsx @@ -0,0 +1,131 @@ +import { Pagination } from '@patternfly/react-core'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { DataView, DataViewTable, DataViewToolbar, useDataViewPagination, useDataViewSelection } from '@patternfly/react-data-view'; +import { useDispatch, useSelector } from 'react-redux'; +import { RBACStore } from '../../redux/store'; +import { fetchUsers } from '../../redux/actions/user-actions'; +import { mappedProps } from '../../helpers/shared/helpers'; +import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups'; +import { Diff } from './EditUserGroupUsersAndServiceAccounts'; + +interface EditGroupUsersTableUsersTableProps { + onChange: (userDiff: Diff) => void; + groupId: string; +} + +const PER_PAGE_OPTIONS = [ + { title: '5', value: 5 }, + { title: '10', value: 10 }, + { title: '20', value: 20 }, + { title: '50', value: 50 }, + { title: '100', value: 100 }, +]; + +const EditGroupUsersTable: React.FunctionComponent = ({ onChange, groupId }) => { + const dispatch = useDispatch(); + const pagination = useDataViewPagination({ perPage: 20 }); + const { page, perPage, onSetPage, onPerPageSelect } = pagination; + const initialUserIds = useRef([]); + + const selection = useDataViewSelection({ + matchOption: (a, b) => a.id === b.id, + }); + const { selected, onSelect, isSelected } = selection; + + useEffect(() => { + return () => { + onSelect(false); + initialUserIds.current = []; + }; + }, []); + + const { users, groupUsers, totalCount } = useSelector((state: RBACStore) => ({ + users: state.userReducer?.users?.data || [], + groupUsers: state.groupReducer?.selectedGroup?.members?.data || [], + totalCount: state.userReducer?.users?.meta?.count, + })); + + const rows = useMemo( + () => + users.map((user) => ({ + id: user.username, + row: [user.is_org_admin ? 'Yes' : 'No', user.username, user.email, user.first_name, user.last_name, user.is_active ? 'Active' : 'Inactive'], + })), + [users, groupId] + ); + + const fetchData = useCallback( + (apiProps: { count: number; limit: number; offset: number; orderBy: string }) => { + const { count, limit, offset, orderBy } = apiProps; + dispatch(fetchUsers({ ...mappedProps({ count, limit, offset, orderBy }), usesMetaInURL: true })); + }, + [dispatch] + ); + + useEffect(() => { + fetchData({ + limit: perPage, + offset: (page - 1) * perPage, + orderBy: 'username', + count: totalCount || 0, + }); + }, [fetchData, page, perPage]); + + useEffect(() => { + onSelect(false); + initialUserIds.current = []; + const initialSelectedUsers = groupUsers.map((user) => ({ id: user.username })); + onSelect(true, initialSelectedUsers); + initialUserIds.current = initialSelectedUsers.map((user) => user.id); + }, [groupUsers]); + + useEffect(() => { + const selectedUserIds = selection.selected.map((user) => user.id); + const added = selectedUserIds.filter((id) => !initialUserIds.current.includes(id)); + const removed = initialUserIds.current.filter((id) => !selectedUserIds.includes(id)); + onChange({ added, removed }); + }, [selection.selected]); + + const pageSelected = rows.length > 0 && rows.every(isSelected); + const pagePartiallySelected = !pageSelected && rows.some(isSelected); + const handleBulkSelect = (value: BulkSelectValue) => { + if (value === BulkSelectValue.none) { + onSelect(false); + } else if (value === BulkSelectValue.page) { + onSelect(true, rows); + } else if (value === BulkSelectValue.nonePage) { + onSelect(false, rows); + } + }; + + return ( + + + } + bulkSelect={ + + } + /> + + + ); +}; + +export default EditGroupUsersTable; diff --git a/src/smart-components/access-management/EditUserGroupUsersAndServiceAccounts.tsx b/src/smart-components/access-management/EditUserGroupUsersAndServiceAccounts.tsx new file mode 100644 index 000000000..43d60d2c9 --- /dev/null +++ b/src/smart-components/access-management/EditUserGroupUsersAndServiceAccounts.tsx @@ -0,0 +1,50 @@ +import { FormGroup, Tab, Tabs } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { UseFieldApiConfig, useFieldApi, useFormApi } from '@data-driven-forms/react-form-renderer'; +import EditGroupServiceAccountsTable from './EditUserGroupServiceAccounts'; +import EditGroupUsersTable from './EditUserGroupUsers'; + +export interface Diff { + added: string[]; + removed: string[]; +} + +export const EditGroupUsersAndServiceAccounts: React.FunctionComponent = (props) => { + const [activeTabKey, setActiveTabKey] = useState(0); + const formOptions = useFormApi(); + const { input, groupId } = useFieldApi(props); + const values = formOptions.getState().values[input.name]; + + const handleUserChange = (users: Diff) => { + input.onChange({ + users, + serviceAccounts: values?.serviceAccounts, + }); + }; + + const handleServiceAccountsChange = (serviceAccounts: Diff) => { + input.onChange({ + users: values?.users, + serviceAccounts, + }); + }; + + const handleTabSelect = (_: React.MouseEvent, key: string | number) => { + setActiveTabKey(Number(key)); + }; + + return ( + + + + + + + + + + + + + ); +}; diff --git a/src/smart-components/access-management/UserGroupsTable.tsx b/src/smart-components/access-management/UserGroupsTable.tsx index 83a7650e8..41b2471f0 100644 --- a/src/smart-components/access-management/UserGroupsTable.tsx +++ b/src/smart-components/access-management/UserGroupsTable.tsx @@ -9,7 +9,7 @@ import { ButtonVariant, EmptyState, EmptyStateBody, EmptyStateHeader, EmptyState import { ActionsColumn } from '@patternfly/react-table'; import { mappedProps } from '../../helpers/shared/helpers'; import { RBACStore } from '../../redux/store'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { fetchGroups, removeGroups } from '../../redux/actions/group-actions'; import { formatDistanceToNow } from 'date-fns'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -54,6 +54,7 @@ const UserGroupsTable: React.FunctionComponent = ({ const [activeState, setActiveState] = useState(DataViewState.loading); const intl = useIntl(); const { trigger } = useDataViewEventsContext(); + const navigate = useNavigate(); const handleDeleteModalToggle = (groups: Group[]) => { setCurrentGroups(groups); @@ -156,7 +157,7 @@ const UserGroupsTable: React.FunctionComponent = ({ items={[ { title: intl.formatMessage(messages['usersAndUserGroupsEditUserGroup']), - onClick: () => console.log('EDIT USER GROUP'), + onClick: () => navigate(`/iam/access-management/users-and-user-groups/edit-group/${group.uuid}`), }, { title: intl.formatMessage(messages['usersAndUserGroupsDeleteUserGroup']), diff --git a/src/utilities/pathnames.js b/src/utilities/pathnames.js index 092da5077..dd2e81274 100644 --- a/src/utilities/pathnames.js +++ b/src/utilities/pathnames.js @@ -208,6 +208,11 @@ const pathnames = { path: '/users-and-user-groups', title: 'Users & User Groups', }, + 'users-and-user-groups-edit-group': { + link: '/users-and-user-groups/edit-group/:groupId', + path: '/users-and-user-groups/edit-group/:groupId', + title: 'Edit group', + }, 'invite-group-users': { link: '/users-and-user-groups/invite', path: '/users-and-user-groups/invite',