From ab5ef7024069ff883b3d98df9586b401f68c4c3d Mon Sep 17 00:00:00 2001 From: jadmsaadaot <91914654+jadmsaadaot@users.noreply.github.com> Date: Fri, 18 Aug 2023 15:00:43 -0700 Subject: [PATCH] Implement deactivate/activate staff user (#2038) * Add toggle user active status backend and front * initialize user status and add loader * fix lint issues * implement todo * fix if statement * Add api tests * Add front end tests --- .../f40da1b8f3e0_initialize_user_status.py | 28 +++++ met-api/src/met_api/models/staff_user.py | 2 +- met-api/src/met_api/resources/staff_user.py | 24 ++++ met-api/src/met_api/schemas/staff_user.py | 1 + met-api/src/met_api/services/keycloak.py | 21 ++++ .../met_api/services/staff_user_service.py | 14 ++- met-api/src/met_api/utils/enums.py | 7 ++ met-api/src/met_api/utils/roles.py | 1 + met-api/tests/unit/api/test_user.py | 87 ++++++++++++- met-api/tests/utilities/factory_scenarios.py | 5 +- met-api/tests/utilities/factory_utils.py | 1 + met-web/src/apiManager/endpoints/index.ts | 1 + .../listing/UserManagementListing.tsx | 9 +- .../userDetails/UserDetails.tsx | 44 ++----- .../userDetails/UserDetailsSkeleton.tsx | 45 +++++++ .../userDetails/UserStatusToggle.tsx | 114 ++++++++++++++++++ met-web/src/models/user.ts | 15 ++- .../src/services/userService/api/index.tsx | 9 ++ met-web/src/services/userService/constants.ts | 1 + .../unit/components/user/UserDetails.test.tsx | 112 +++++++++++++++++ .../unit/components/user/UserListing.test.tsx | 2 +- 21 files changed, 497 insertions(+), 46 deletions(-) create mode 100644 met-api/migrations/versions/f40da1b8f3e0_initialize_user_status.py create mode 100644 met-web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx create mode 100644 met-web/src/components/userManagement/userDetails/UserStatusToggle.tsx create mode 100644 met-web/tests/unit/components/user/UserDetails.test.tsx diff --git a/met-api/migrations/versions/f40da1b8f3e0_initialize_user_status.py b/met-api/migrations/versions/f40da1b8f3e0_initialize_user_status.py new file mode 100644 index 000000000..f8cb1999c --- /dev/null +++ b/met-api/migrations/versions/f40da1b8f3e0_initialize_user_status.py @@ -0,0 +1,28 @@ +""" Initialize user status +P +Revision ID: f40da1b8f3e0 +Revises: 31041fb90d53 +Create Date: 2023-08-18 09:50:27.567044 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'f40da1b8f3e0' +down_revision = '31041fb90d53' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("UPDATE staff_users SET status_id = 1") + op.alter_column('survey', 'is_template', nullable=False, server_default='1') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('survey', 'is_template', nullable=True, server_default=None) + op.execute("UPDATE staff_users SET status_id = None") + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/staff_user.py b/met-api/src/met_api/models/staff_user.py index 524c9ccb3..ede2af219 100644 --- a/met-api/src/met_api/models/staff_user.py +++ b/met-api/src/met_api/models/staff_user.py @@ -31,7 +31,7 @@ class StaffUser(BaseModel): email_address = Column(db.String(100), nullable=True) contact_number = Column(db.String(50), nullable=True) external_id = Column(db.String(50), nullable=False, unique=True) - status_id = db.Column(db.Integer, ForeignKey('user_status.id')) + status_id = db.Column(db.Integer, ForeignKey('user_status.id'), nullable=False, server_default='1') tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True) @classmethod diff --git a/met-api/src/met_api/resources/staff_user.py b/met-api/src/met_api/resources/staff_user.py index 94ba7adad..80368bb38 100644 --- a/met-api/src/met_api/resources/staff_user.py +++ b/met-api/src/met_api/resources/staff_user.py @@ -93,6 +93,30 @@ def get(user_id): return user, HTTPStatus.OK +@cors_preflight('PATCH') +@API.route('//status') +class StaffUserStatus(Resource): + """User controller class.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.has_one_of_roles([Role.TOGGLE_USER_STATUS.value]) + def patch(user_id): + """Return a set of users(staff only).""" + try: + data = request.get_json() + if data.get('active', None) is None: + return {'message': 'active field is required'}, HTTPStatus.BAD_REQUEST + + user = StaffUserService.toggle_user_active_status( + user_id, + active=data.get('active'), + ) + return user, HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.BAD_REQUEST + + @cors_preflight('POST') @API.route('//groups') class UserGroup(Resource): diff --git a/met-api/src/met_api/schemas/staff_user.py b/met-api/src/met_api/schemas/staff_user.py index f1b26f568..91cec6535 100644 --- a/met-api/src/met_api/schemas/staff_user.py +++ b/met-api/src/met_api/schemas/staff_user.py @@ -26,3 +26,4 @@ class Meta: # pylint: disable=too-few-public-methods updated_date = fields.Str(data_key='updated_date') roles = fields.List(fields.Str(data_key='roles')) tenant_id = fields.Str(data_key='tenant_id') + status_id = fields.Int(data_key='status_id') diff --git a/met-api/src/met_api/services/keycloak.py b/met-api/src/met_api/services/keycloak.py index fc5fbff64..2e7b32360 100644 --- a/met-api/src/met_api/services/keycloak.py +++ b/met-api/src/met_api/services/keycloak.py @@ -204,3 +204,24 @@ def get_user_by_username(username, admin_token=None): query_user_url = f'{base_url}/auth/admin/realms/{realm}/users?username={username}' response = requests.get(query_user_url, headers=headers, timeout=timeout) return response.json()[0] + + @staticmethod + def toggle_user_enabled_status(user_id, enabled): + """Toggle the enabled status of a user in Keycloak.""" + base_url = current_app.config.get('KEYCLOAK_BASE_URL') + realm = current_app.config.get('KEYCLOAK_REALMNAME') + timeout = current_app.config.get('CONNECT_TIMEOUT', 60) + admin_token = KeycloakService._get_admin_token() + headers = { + 'Content-Type': ContentType.JSON.value, + 'Authorization': f'Bearer {admin_token}' + } + + user_data = { + 'enabled': enabled # Set the user's enabled status based on 'enable' parameter + } + + # Update the user's enabled status + update_user_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}' + response = requests.put(update_user_url, json=user_data, headers=headers, timeout=timeout) + response.raise_for_status() diff --git a/met-api/src/met_api/services/staff_user_service.py b/met-api/src/met_api/services/staff_user_service.py index aa7e415fa..54289714a 100644 --- a/met-api/src/met_api/services/staff_user_service.py +++ b/met-api/src/met_api/services/staff_user_service.py @@ -11,7 +11,7 @@ from met_api.services.keycloak import KeycloakService from met_api.utils import notification from met_api.utils.constants import GROUP_NAME_MAPPING, Groups -from met_api.utils.enums import KeycloakGroupName +from met_api.utils.enums import KeycloakGroupName, UserStatus from met_api.utils.template import Template @@ -175,3 +175,15 @@ def validate_user(db_user: StaffUserModel): raise BusinessException( error='This user is already a Superuser.', status_code=HTTPStatus.CONFLICT.value) + + @staticmethod + def toggle_user_active_status(user_external_id: str, active: bool): + """Toggle user active status.""" + db_user = StaffUserModel.get_user_by_external_id(user_external_id) + if db_user is None: + raise KeyError('User not found') + + KEYCLOAK_SERVICE.toggle_user_enabled_status(user_id=user_external_id, enabled=active) + db_user.status_id = UserStatus.ACTIVE.value if active else UserStatus.INACTIVE.value + db_user.save() + return StaffUserSchema().dump(db_user) diff --git a/met-api/src/met_api/utils/enums.py b/met-api/src/met_api/utils/enums.py index ec46934b0..2d2324e98 100644 --- a/met-api/src/met_api/utils/enums.py +++ b/met-api/src/met_api/utils/enums.py @@ -79,3 +79,10 @@ class SourceAction(Enum): CREATED = 'created' PUBLISHED = 'published' + + +class UserStatus(IntEnum): + """User status.""" + + ACTIVE = 1 + INACTIVE = 2 diff --git a/met-api/src/met_api/utils/roles.py b/met-api/src/met_api/utils/roles.py index cd27a2ed5..3d47a0b4c 100644 --- a/met-api/src/met_api/utils/roles.py +++ b/met-api/src/met_api/utils/roles.py @@ -25,6 +25,7 @@ class Role(Enum): CREATE_TENANT = 'create_tenant' VIEW_TENANT = 'view_tenant' VIEW_USERS = 'view_users' + TOGGLE_USER_STATUS = 'toggle_user_status' CREATE_ADMIN_USER = 'create_admin_user' CREATE_TEAM = 'create_team' CREATE_ENGAGEMENT = 'create_engagement' diff --git a/met-api/tests/unit/api/test_user.py b/met-api/tests/unit/api/test_user.py index 407dea90d..cb692c020 100644 --- a/met-api/tests/unit/api/test_user.py +++ b/met-api/tests/unit/api/test_user.py @@ -23,7 +23,7 @@ from flask import current_app from met_api.models import Tenant as TenantModel -from met_api.utils.enums import ContentType, KeycloakGroupName +from met_api.utils.enums import ContentType, KeycloakGroupName, UserStatus from tests.utilities.factory_scenarios import TestJwtClaims, TestUserInfo from tests.utilities.factory_utils import factory_auth_header, factory_staff_user_model @@ -135,3 +135,88 @@ def test_add_user_to_team_member_group(mocker, client, jwt, session): assert rv.status_code == HTTPStatus.OK mock_add_user_to_group_keycloak.assert_called() mock_get_user_groups_keycloak.assert_called() + + +def mock_toggle_user_status(mocker): + """Mock the KeycloakService.add_user_to_group method.""" + mock_response = MagicMock() + mock_response.status_code = HTTPStatus.NO_CONTENT + + mock_toggle_user_status = mocker.patch( + f'{KEYCLOAK_SERVICE_MODULE}.toggle_user_enabled_status', + return_value=mock_response + ) + + return mock_toggle_user_status + + +def test_toggle_user_active_status(mocker, client, jwt, session): + """Assert that a user can be toggled.""" + user = factory_staff_user_model() + mocked_toggle_user_status = mock_toggle_user_status(mocker) + + assert user.status_id == UserStatus.ACTIVE.value + claims = TestJwtClaims.staff_admin_role + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.patch( + f'/api/user/{user.external_id}/status', + headers=headers, + json={'active': False}, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('status_id') == UserStatus.INACTIVE.value + mocked_toggle_user_status.assert_called() + + +def test_team_member_cannot_toggle_user_active_status(mocker, client, jwt, session): + """Assert that a team member cannot toggle user status.""" + user = factory_staff_user_model() + mocked_toggle_user_status = mock_toggle_user_status(mocker) + + assert user.status_id == UserStatus.ACTIVE.value + claims = TestJwtClaims.team_member_role + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.patch( + f'/api/user/{user.external_id}/status', + headers=headers, + json={'active': False}, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.UNAUTHORIZED + mocked_toggle_user_status.assert_not_called() + + +def test_reviewer_cannot_toggle_user_active_status(mocker, client, jwt, session): + """Assert that a reviewer cannot toggle user status.""" + user = factory_staff_user_model() + mocked_toggle_user_status = mock_toggle_user_status(mocker) + + assert user.status_id == UserStatus.ACTIVE.value + claims = TestJwtClaims.reviewer_role + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.patch( + f'/api/user/{user.external_id}/status', + headers=headers, + json={'active': False}, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.UNAUTHORIZED + mocked_toggle_user_status.assert_not_called() + + +def test_toggle_user_active_status_empty_body(mocker, client, jwt, session): + """Assert that returns bad request if bad request body.""" + user = factory_staff_user_model() + mocked_toggle_user_status = mock_toggle_user_status(mocker) + + assert user.status_id == UserStatus.ACTIVE.value + claims = TestJwtClaims.staff_admin_role + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.patch( + f'/api/user/{user.external_id}/status', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + mocked_toggle_user_status.assert_not_called() diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 62ba2e060..a6bb975db 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -28,7 +28,7 @@ from met_api.constants.feedback import CommentType, FeedbackSourceType, RatingType from met_api.constants.widget import WidgetType -from met_api.utils.enums import LoginSource +from met_api.utils.enums import LoginSource, UserStatus fake = Faker() @@ -42,6 +42,7 @@ class TestUserInfo(dict, Enum): user = { 'id': 123, 'first_name': 'System', + 'status_id': UserStatus.ACTIVE.value, } user_staff_1 = { @@ -49,6 +50,7 @@ class TestUserInfo(dict, Enum): 'middle_name': fake.name(), 'last_name': fake.name(), 'email_address': fake.email(), + 'status_id': UserStatus.ACTIVE.value, } @@ -298,6 +300,7 @@ class TestJwtClaims(dict, Enum): 'review_comments', 'review_all_comments', 'view_all_engagements', + 'toggle_user_status', ] } } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 8c72e5fbc..9f4ffe8f6 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -161,6 +161,7 @@ def factory_staff_user_model(external_id=None, user_info: dict = TestUserInfo.us middle_name=user_info['middle_name'], email_address=user_info['email_address'], external_id=str(external_id), + status_id=user_info['status_id'], ) user.save() return user diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index e1b587d96..47a7cec62 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -28,6 +28,7 @@ const Endpoints = { GET_LIST: `${AppConfig.apiUrl}/user/`, ADD_TO_GROUP: `${AppConfig.apiUrl}/user/user_id/groups`, GET_USER_ENGAGEMENTS: `${AppConfig.apiUrl}/user/user_id/engagements`, + TOGGLE_USER_STATUS: `${AppConfig.apiUrl}/user/user_id/status`, }, Document: { OSS_HEADER: `${AppConfig.apiUrl}/document/`, diff --git a/met-web/src/components/userManagement/listing/UserManagementListing.tsx b/met-web/src/components/userManagement/listing/UserManagementListing.tsx index 5701afd40..ea266e51d 100644 --- a/met-web/src/components/userManagement/listing/UserManagementListing.tsx +++ b/met-web/src/components/userManagement/listing/UserManagementListing.tsx @@ -3,7 +3,7 @@ import TextField from '@mui/material/TextField'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; import SearchIcon from '@mui/icons-material/Search'; -import { User } from 'models/user'; +import { USER_STATUS, User } from 'models/user'; import { HeadCell, PaginationOptions } from 'components/common/Table/types'; import { MetPageGridContainer, PrimaryButton } from 'components/common'; import { Link } from 'react-router-dom'; @@ -49,14 +49,13 @@ const UserManagementListing = () => { renderCell: (row: User) => formatDate(row.created_date), }, { - key: 'status', + key: 'status_id', numeric: false, disablePadding: true, label: 'Status', allowSort: true, - /* TODO Hardcoded value since currently we have all users as active. - Need to change once we have different user status */ - renderCell: () => 'Active', + renderCell: (row: User) => + Object.values(USER_STATUS).find((status) => status.value === row.status_id)?.label ?? '', }, { key: 'id', diff --git a/met-web/src/components/userManagement/userDetails/UserDetails.tsx b/met-web/src/components/userManagement/userDetails/UserDetails.tsx index 744ff5f7b..952adc56b 100644 --- a/met-web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/met-web/src/components/userManagement/userDetails/UserDetails.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState, ChangeEvent } from 'react'; +import React, { useContext, useState } from 'react'; import { Grid, FormControlLabel, Switch } from '@mui/material'; import { MetLabel, MetParagraph, MetPageGridContainer, PrimaryButton } from 'components/common'; import { useAppSelector, useAppDispatch } from 'hooks'; @@ -7,15 +7,16 @@ import { openNotificationModal } from 'services/notificationModalService/notific import { openNotification } from 'services/notificationService/notificationSlice'; import { formatDate } from 'components/common/dateHelper'; import AssignedEngagementsListing from './AssignedEngagementsListing'; +import UserStatusToggle from './UserStatusToggle'; +import UserDetailsSkeleton from './UserDetailsSkeleton'; export const UserDetails = () => { const { roles } = useAppSelector((state) => state.user); - const { savedUser, setAddUserModalOpen } = useContext(UserDetailsContext); + const { savedUser, setAddUserModalOpen, isUserLoading } = useContext(UserDetailsContext); const [superUserAssigned, setSuperUser] = useState(false); - const [deactivatedUser, setDeactivatedUser] = useState(false); const dispatch = useAppDispatch(); - const handleToggleChange = (event: ChangeEvent, checked: boolean) => { + const handleToggleChange = () => { if (roles.includes('SuperUser')) { dispatch( openNotificationModal({ @@ -41,32 +42,10 @@ export const UserDetails = () => { dispatch(openNotification({ severity: 'error', text: 'You do not have permissions to give user roles' })); } }; - const handleUserDeactivated = (event: ChangeEvent, checked: boolean) => { - if (roles.includes('SuperUser')) { - dispatch( - openNotificationModal({ - open: true, - data: { - header: `Remove SuperUser role from ${savedUser?.username}`, - subText: [ - { - text: `You are attempting to remove the SuperUser role from ${savedUser?.username}`, - }, - { - text: 'Are you sure?', - }, - ], - handleConfirm: () => { - setDeactivatedUser(!deactivatedUser); - }, - }, - type: 'confirm', - }), - ); - } else { - dispatch(openNotification({ severity: 'error', text: 'You do not have permissions to remove user roles' })); - } - }; + + if (isUserLoading) { + return ; + } return ( @@ -120,10 +99,7 @@ export const UserDetails = () => { - } - label={Deactivate User} - /> + diff --git a/met-web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx b/met-web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx new file mode 100644 index 000000000..79fc4fd16 --- /dev/null +++ b/met-web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Grid, Skeleton } from '@mui/material'; +import { MetPageGridContainer } from 'components/common'; + +export const UserDetailsSkeleton = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default UserDetailsSkeleton; diff --git a/met-web/src/components/userManagement/userDetails/UserStatusToggle.tsx b/met-web/src/components/userManagement/userDetails/UserStatusToggle.tsx new file mode 100644 index 000000000..301ef35db --- /dev/null +++ b/met-web/src/components/userManagement/userDetails/UserStatusToggle.tsx @@ -0,0 +1,114 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { FormControlLabel, Switch, CircularProgress } from '@mui/material'; +import { MetLabel } from 'components/common'; +import { useAppSelector, useAppDispatch } from 'hooks'; +import { UserDetailsContext } from './UserDetailsContext'; +import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { toggleUserStatus } from 'services/userService/api'; +import { USER_ROLES } from 'services/userService/constants'; + +const UserStatusToggle = () => { + const { roles } = useAppSelector((state) => state.user); + const { savedUser } = useContext(UserDetailsContext); + const [userStatus, setUserStatus] = useState(false); + const [togglingUserStatus, setTogglingUserStatus] = useState(false); + const dispatch = useAppDispatch(); + + useEffect(() => { + setUserStatus(savedUser?.status_id === 1); + }, [savedUser]); + + const handleUpdateActiveStatus = async (active: boolean) => { + if (!savedUser) { + return; + } + + try { + setUserStatus(active); + setTogglingUserStatus(true); + await toggleUserStatus(savedUser.external_id, active); + setTogglingUserStatus(false); + } catch (error) { + setUserStatus(!active); + setTogglingUserStatus(false); + dispatch(openNotification({ severity: 'error', text: 'Failed to update user status' })); + } + }; + + const handleToggleUserStatus = async (active: boolean) => { + if (!roles.includes(USER_ROLES.TOGGLE_USER_STATUS)) { + dispatch( + openNotification({ severity: 'error', text: 'You do not have permissions to update user status' }), + ); + return; + } + + if (active) { + return handleActivateUser(); + } + + return handleDeactivateUser(); + }; + const handleDeactivateUser = () => { + dispatch( + openNotificationModal({ + open: true, + data: { + header: `Deactivate User`, + subText: [ + { + text: `You are attempting to deactivate ${savedUser?.first_name} ${savedUser?.last_name}`, + }, + { + text: 'Are you sure?', + }, + ], + handleConfirm: () => { + handleUpdateActiveStatus(false); + }, + }, + type: 'confirm', + }), + ); + }; + + const handleActivateUser = () => { + dispatch( + openNotificationModal({ + open: true, + data: { + header: `Activate User`, + subText: [ + { + text: `You are attempting to activate ${savedUser?.first_name} ${savedUser?.last_name}`, + }, + { + text: 'Are you sure?', + }, + ], + handleConfirm: () => { + handleUpdateActiveStatus(true); + }, + }, + type: 'confirm', + }), + ); + }; + + return ( + handleToggleUserStatus(e.target.checked)} + /> + } + label={togglingUserStatus ? : Active} + /> + ); +}; + +export default UserStatusToggle; diff --git a/met-web/src/models/user.ts b/met-web/src/models/user.ts index 354fc90b5..4dac53743 100644 --- a/met-web/src/models/user.ts +++ b/met-web/src/models/user.ts @@ -30,12 +30,23 @@ export interface User { id: number; last_name: string; updated_date: string; - status: string; roles: string[]; main_group: string; username: string; + status_id: number; } +export const USER_STATUS: { [x: string]: { value: number; label: string } } = { + ACTIVE: { + value: 1, + label: 'Active', + }, + INACTIVE: { + value: 2, + label: 'Inactive', + }, +}; + export const createDefaultUser: User = { id: 0, contact_number: '', @@ -47,8 +58,8 @@ export const createDefaultUser: User = { last_name: '', updated_date: Date(), created_date: Date(), - status: '', roles: [], username: '', main_group: '', + status_id: 0, }; diff --git a/met-web/src/services/userService/api/index.tsx b/met-web/src/services/userService/api/index.tsx index da15c3b23..a34d235f9 100644 --- a/met-web/src/services/userService/api/index.tsx +++ b/met-web/src/services/userService/api/index.tsx @@ -60,3 +60,12 @@ export const fetchUserEngagements = async ({ user_id }: GetUserEngagementsParams const responseData = await http.GetRequest(url); return responseData.data ?? []; }; + +export const toggleUserStatus = async (user_id: string, active: boolean): Promise => { + const url = replaceUrl(Endpoints.User.TOGGLE_USER_STATUS, 'user_id', String(user_id)); + const data = { + active, + }; + const responseData = await http.PatchRequest(url, data); + return responseData.data; +}; diff --git a/met-web/src/services/userService/constants.ts b/met-web/src/services/userService/constants.ts index 68de89af5..b51591f53 100644 --- a/met-web/src/services/userService/constants.ts +++ b/met-web/src/services/userService/constants.ts @@ -30,4 +30,5 @@ export const USER_ROLES = { VIEW_SURVEYS: 'view_surveys', VIEW_FEEDBACKS: 'view_feedbacks', SHOW_ALL_COMMENT_STATUS: 'show_all_comment_status', + TOGGLE_USER_STATUS: 'toggle_user_status', }; diff --git a/met-web/tests/unit/components/user/UserDetails.test.tsx b/met-web/tests/unit/components/user/UserDetails.test.tsx new file mode 100644 index 000000000..2bf8b7cb0 --- /dev/null +++ b/met-web/tests/unit/components/user/UserDetails.test.tsx @@ -0,0 +1,112 @@ +import React, { ReactNode } from 'react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { setupEnv } from '../setEnvVars'; +import * as reactRedux from 'react-redux'; +import * as userService from 'services/userService/api'; +import * as membershipService from 'services/membershipService'; +import * as notificationModalSlice from 'services/notificationModalService/notificationModalSlice'; +import { User, createDefaultUser } from 'models/user'; +import { draftEngagement } from '../factory'; +import { EngagementTeamMember, initialDefaultTeamMember } from 'models/engagementTeamMember'; +import UserProfile from 'components/userManagement/userDetails'; +import { USER_ROLES } from 'services/userService/constants'; + +const mockUser1: User = { + ...createDefaultUser, + id: 1, + contact_number: '555 012 4564', + description: 'mock description', + email_address: '1', + external_id: '3859G58GJH3921', + first_name: 'Mock first name', + last_name: 'Mock last name', + updated_date: Date(), + created_date: Date(), + status_id: 1, + roles: [], +}; + +const mockMembership: EngagementTeamMember = { + ...initialDefaultTeamMember, + id: 1, + engagement_id: draftEngagement.id, + user: { + ...mockUser1 + }, + user_id: mockUser1.id, + engagement: { + ...draftEngagement + } +} + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Link: ({ children }: { children: ReactNode }) => { + return {children}; + }, +})); + +jest.mock('components/common', () => ({ + ...jest.requireActual('components/common'), + PrimaryButton: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => { + return ; + }, +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.TOGGLE_USER_STATUS], + }; + }), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useParams: jest.fn(() => ({ userId: '1' })), +})); + +describe('User Details tests', () => { + jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); + const mockOpenNotificationModal = jest.spyOn(notificationModalSlice, 'openNotificationModal').mockImplementation(jest.fn()); + jest.spyOn(userService, 'getUser').mockReturnValue( + Promise.resolve(mockUser1), + ); + jest.spyOn(membershipService, 'getMembershipsByUser').mockReturnValue( + Promise.resolve([mockMembership]), + ); + + beforeEach(() => { + setupEnv(); + }); + + test('User details page is rendered', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(draftEngagement.name)).toBeVisible(); + expect(screen.getByText(mockUser1.first_name)).toBeVisible(); + expect(screen.getByTestId('user-status-toggle').children[0]).toBeChecked(); + }); + + }); + + test('Confirmation model appears when toggling status', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('user-status-toggle').children[0]).toBeChecked(); + }); + + const toggle = screen.getByTestId('user-status-toggle').children[0]; + fireEvent.click(toggle); + + await waitFor(() => { + expect(mockOpenNotificationModal).toHaveBeenCalled(); + }); + + }); +}); diff --git a/met-web/tests/unit/components/user/UserListing.test.tsx b/met-web/tests/unit/components/user/UserListing.test.tsx index 64db924df..0c5123b2a 100644 --- a/met-web/tests/unit/components/user/UserListing.test.tsx +++ b/met-web/tests/unit/components/user/UserListing.test.tsx @@ -20,7 +20,7 @@ const mockUser1: User = { last_name: 'Mock last name', updated_date: Date(), created_date: Date(), - status: 'Active', + status_id: 1, roles: [], };