diff --git a/CHANGELOG.MD b/CHANGELOG.MD index a1a040b43..823def224 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,6 +1,13 @@ ## July 30, 2024 -- **Task** Update Contributing guide, pull request template [🎟️ DESENG-651](https://citz-gdx.atlassian.net/browse/DESENG-651) +- **Feature** New admin header [🎟️ DESENG-660](https://citz-gdx.atlassian.net/browse/DESENG-660) + - Added a new API endpoint to fetch a list of a user's tenants, required for the new header + - Redesigned the admin header to match the new design system + - Added a new dropdown to the header to allow users to switch between tenants + - Added a user menu, which for now only contains a logout button + - Added a "drawer" view for the tenant switcher and user menu on mobile + +- **Task** Update Contributing guide, pull request template [🎟️ DESENG-651](https://citz-gdx.atlassian.net/browse/DESENG-651) ## July 25, 2024 @@ -11,6 +18,7 @@ ## July 17, 2024 - **Feature** Admin authoring experience - language selector [🎟️ DESENG-657](https://citz-gdx.atlassian.net/browse/DESENG-657) + - Created loader for engagement creation wizard - Created language selector field in engagement creation wizard diff --git a/met-api/src/met_api/models/user_group_membership.py b/met-api/src/met_api/models/user_group_membership.py index 24a8e6b7e..07f5a481e 100644 --- a/met-api/src/met_api/models/user_group_membership.py +++ b/met-api/src/met_api/models/user_group_membership.py @@ -6,6 +6,7 @@ from flask import g from sqlalchemy import PrimaryKeyConstraint + from .base_model import BaseModel from .db import db @@ -21,6 +22,7 @@ class UserGroupMembership(BaseModel): # pylint: disable=too-few-public-methods, tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), primary_key=True, nullable=False) is_active = db.Column(db.Boolean, nullable=False) + tenant = db.relationship('Tenant', backref='user_group_memberships') groups = db.relationship('UserGroup', backref='user_group_membership') __table_args__ = ( @@ -28,12 +30,18 @@ class UserGroupMembership(BaseModel): # pylint: disable=too-few-public-methods, ) @classmethod - def get_group_by_user_id(cls, external_id, tenant_id): + def get_group_by_user_and_tenant_id(cls, external_id, tenant_id): """Get group by user id.""" return db.session.query(UserGroupMembership).filter( UserGroupMembership.staff_user_external_id == external_id, UserGroupMembership.tenant_id == tenant_id).first() + @classmethod + def get_groups_by_user_id(cls, external_id): + """Get groups by user id.""" + return db.session.query(UserGroupMembership).filter( + UserGroupMembership.staff_user_external_id == external_id).all() + @classmethod def create_user_group_membership(cls, membership_data: dict) -> UserGroupMembership: """Create a user group membership.""" diff --git a/met-api/src/met_api/resources/staff_user.py b/met-api/src/met_api/resources/staff_user.py index 9ae9acd5d..ecb18d14f 100644 --- a/met-api/src/met_api/resources/staff_user.py +++ b/met-api/src/met_api/resources/staff_user.py @@ -19,14 +19,19 @@ from flask_cors import cross_origin from flask_restx import Namespace, Resource +from met_api.auth import auth from met_api.auth import jwt as _jwt from met_api.exceptions.business_exception import BusinessException from met_api.models.pagination_options import PaginationOptions from met_api.schemas.engagement import EngagementSchema from met_api.schemas.staff_user import StaffUserSchema +from met_api.schemas.tenant import TenantSchema +from met_api.services import authorization from met_api.services.membership_service import MembershipService from met_api.services.staff_user_membership_service import StaffUserMembershipService from met_api.services.staff_user_service import StaffUserService +from met_api.services.tenant_service import TenantService +from met_api.services.user_group_membership_service import UserGroupMembershipService from met_api.utils.roles import Role from met_api.utils.tenant_validator import require_role from met_api.utils.token_info import TokenInfo @@ -52,7 +57,9 @@ def put(): user_data = TokenInfo.get_user_data() user = StaffUserService().create_or_update_user(user_data) user.roles = current_app.config['JWT_ROLE_CALLBACK'](g.jwt_oidc_token_info) - return StaffUserSchema().dump(user), HTTPStatus.OK + user_info = StaffUserSchema().dump(user) + StaffUserService.attach_roles([user_info]) + return user_info, HTTPStatus.OK except KeyError as err: return str(err), HTTPStatus.BAD_REQUEST except ValueError as err: @@ -176,3 +183,36 @@ def get(user_id): return jsonify(engagement_schema.dump(members, many=True)), HTTPStatus.OK except BusinessException as err: return {'message': err.error}, err.status_code + + +@cors_preflight('GET,OPTIONS') +@API.route('//tenants') +class UserTenants(Resource): + """Fetches tenants for a given user.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @auth.requires_auth + def get(user_id): + """Get tenant details by user id.""" + if user_id == 'me': + user_data = TokenInfo.get_user_data() + user_id = user_data.get('external_id') + print('User ID: ', user_id) + user_roles = current_app.config['JWT_ROLE_CALLBACK'](g.jwt_oidc_token_info) + if Role.SUPER_ADMIN.value in user_roles: + return TenantService.get_all(), HTTPStatus.OK + else: + authorization.check_auth( + one_of_roles=( + Role.VIEW_USERS.value, + ), + user_id=user_id + ) + + try: + members = UserGroupMembershipService.get_user_memberships(user_id) + tenants = TenantSchema().dumps([member.tenant for member in members], many=True) + return tenants, HTTPStatus.OK + except BusinessException as err: + return {'message': err.error}, err.status_code diff --git a/met-api/src/met_api/services/user_group_membership_service.py b/met-api/src/met_api/services/user_group_membership_service.py index 791e4ebae..9eecaa5e5 100644 --- a/met-api/src/met_api/services/user_group_membership_service.py +++ b/met-api/src/met_api/services/user_group_membership_service.py @@ -14,7 +14,7 @@ def get_user_roles_within_tenant(cls, external_id, tenant_id) -> Tuple[List[str] user_roles = [] # Get the group membership for the user - user_membership = UserGroupMembership.get_group_by_user_id(external_id, tenant_id) + user_membership = UserGroupMembership.get_group_by_user_and_tenant_id(external_id, tenant_id) # Get all role mappings for the groups if user_membership: @@ -35,9 +35,14 @@ def get_user_roles_within_tenant(cls, external_id, tenant_id) -> Tuple[List[str] def get_user_group_within_tenant(cls, external_id, tenant_id): """Get the group to which a user belongs based on their external ID.""" # Get the group membership for the user - user_memberships = UserGroupMembership.get_group_by_user_id(external_id, tenant_id) + user_memberships = UserGroupMembership.get_group_by_user_and_tenant_id(external_id, tenant_id) return user_memberships.groups.name if user_memberships else None + @classmethod + def get_user_memberships(cls, external_id: str): + """Get all group memberships for a user based on their external ID.""" + return UserGroupMembership.get_groups_by_user_id(external_id) + @staticmethod def assign_composite_role_to_user(membership_data): """Create user_group_membership.""" diff --git a/met-web/src/App.tsx b/met-web/src/App.tsx index 708535363..d8fd830de 100644 --- a/met-web/src/App.tsx +++ b/met-web/src/App.tsx @@ -27,13 +27,13 @@ import { AuthKeyCloakContext } from './components/auth/AuthKeycloakContext'; import { determinePathSegments, findTenantInPath } from './utils'; import { AuthenticatedLayout } from 'components/appLayouts/AuthenticatedLayout'; import { PublicLayout } from 'components/appLayouts/PublicLayout'; +import { authenticatedRootLoader } from 'routes/AuthenticatedRootRouteLoader'; interface Translations { [languageId: string]: { [key: string]: string }; } const App = () => { - const drawerWidth = 300; const dispatch = useAppDispatch(); const roles = useAppSelector((state) => state.user.roles); const authenticationLoading = useAppSelector((state) => state.user.authentication.loading); @@ -186,7 +186,7 @@ const App = () => { loadTranslation(); }, [language.id, translations]); - if (authenticationLoading || tenant.loading) { + if (authenticationLoading || tenant.loading || !tenant.short_name) { return ; } @@ -222,13 +222,15 @@ const App = () => { const router = createBrowserRouter( [ { - element: , + element: , children: [ { path: '*', element: , }, ], + loader: authenticatedRootLoader, + id: 'authenticated-root', }, ], { basename: `/${basename}` }, @@ -240,11 +242,13 @@ const App = () => { const router = createBrowserRouter( [ { - element: , + element: , children: createRoutesFromElements(AuthenticatedRoutes()), handle: { crumb: () => ({ name: 'Dashboard', link: '/home' }), }, + id: 'authenticated-root', + loader: authenticatedRootLoader, }, ], { basename: `/${basename}` }, diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 8e1a2bdb1..dac9f9eb0 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -174,8 +174,9 @@ const Endpoints = { }, Tenants: { CREATE: `${AppConfig.apiUrl}/tenants/`, - GET_LIST: `${AppConfig.apiUrl}/tenants/`, GET: `${AppConfig.apiUrl}/tenants/tenant_id`, + GET_LIST: `${AppConfig.apiUrl}/tenants/`, + GET_OWN: `${AppConfig.apiUrl}/user/me/tenants`, UPDATE: `${AppConfig.apiUrl}/tenants/tenant_id`, DELETE: `${AppConfig.apiUrl}/tenants/tenant_id`, }, diff --git a/met-web/src/components/appLayouts/AuthenticatedLayout.tsx b/met-web/src/components/appLayouts/AuthenticatedLayout.tsx index ac1fa352d..1bb04d220 100644 --- a/met-web/src/components/appLayouts/AuthenticatedLayout.tsx +++ b/met-web/src/components/appLayouts/AuthenticatedLayout.tsx @@ -14,15 +14,15 @@ import FormioListener from 'components/FormioListener'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -export const AuthenticatedLayout = ({ drawerWidth = 280 }: { drawerWidth?: number }) => { +export const AuthenticatedLayout = () => { return ( <> - + - + = (props: BoxProps) => { diff --git a/met-web/src/components/common/Navigation/DropdownMenu.tsx b/met-web/src/components/common/Navigation/DropdownMenu.tsx new file mode 100644 index 000000000..53578d0ef --- /dev/null +++ b/met-web/src/components/common/Navigation/DropdownMenu.tsx @@ -0,0 +1,139 @@ +import React, { useId, useRef, useState } from 'react'; +import { + ButtonBase, + Grid, + MenuList, + Popper, + ClickAwayListener, + MenuListProps, + ButtonBaseProps, + PopperProps, +} from '@mui/material'; +import TrapFocus from '@mui/base/TrapFocus'; +import { colors } from 'styles/Theme'; +import { BodyText } from 'components/common/Typography'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronDown } from '@fortawesome/pro-regular-svg-icons'; +import { When, Unless } from 'react-if'; +import { elevations } from 'components/common'; + +export const dropdownMenuStyles = { + padding: '1rem', + borderRadius: '8px', + border: '1px solid transparent', + '&:hover': { backgroundColor: colors.surface.blue[80] }, + '&:focus-visible': { + backgroundColor: colors.surface.blue[80], + border: '1px dashed white', + }, +}; + +export const ToggleNav = ({ isNav, children }: { isNav?: boolean; children: React.ReactNode }) => { + if (isNav) { + return ; + } + return <>{children}; +}; + +export const DropdownMenu = ({ + name, + forNavigation, + buttonContent, + buttonProps, + children, + popperProps, + ...props +}: { + name?: string; + forNavigation?: boolean; + buttonContent?: ({ isOpen }: { isOpen: boolean }) => React.ReactNode; + buttonProps?: ButtonBaseProps; + popperProps?: Partial; + children?: React.ReactNode; +} & MenuListProps) => { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + const dropdownId = useId(); + + return ( + <> + {/* Dropdown Button */} + { + setOpen(!open); + }} + {...buttonProps} + sx={{ + ...dropdownMenuStyles, + ...buttonProps?.sx, + }} + > + {/* Pass the "open" state to the button contents in case they want to change based on dropdown state */} + {buttonContent?.({ isOpen: open })} + {/* A basic button label if no custom content is provided */} + + + + {name} + + + + + + { + if (e.target !== buttonRef.current && !buttonRef.current?.contains(e.target as Node)) + setOpen(false); + }} + > + {/* Dropdown Contents */} + { + // listen for escape key to close menu + if (e.key === 'Escape') setOpen(false); + }} + id={dropdownId} + anchorEl={buttonRef.current} + placement="bottom-start" + modifiers={[{ name: 'offset', options: { offset: [0, 8] } }]} + open={open} + {...popperProps} + sx={{ + zIndex: 10000, + boxShadow: elevations.default, + backgroundColor: colors.surface.blue[90], + padding: '2px', + borderRadius: '16px', + minWidth: 'fit-content', + width: buttonRef.current?.offsetWidth, + ...popperProps?.sx, + }} + > + + + + {children} + + + + + + + ); +}; +export default DropdownMenu; diff --git a/met-web/src/components/engagement/listing/index.tsx b/met-web/src/components/engagement/listing/index.tsx index fc3383fa9..db7677e95 100644 --- a/met-web/src/components/engagement/listing/index.tsx +++ b/met-web/src/components/engagement/listing/index.tsx @@ -394,7 +394,9 @@ const EngagementListing = () => { columnSpacing={2} rowSpacing={1} > - + + + { + const isMediumScreenOrLarger = useMediaQuery((theme: Theme) => theme.breakpoints.up('md')); + const isMobileScreen = !useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')); + const [sideNavOpen, setSideNavOpen] = useState(false); + const [secondaryMenuOpen, setSecondaryMenuOpen] = useState(false); + const tenant = useAppSelector((state) => state.tenant); + const user = useAppSelector((state) => state.user); + const canNavigate = user.roles.length !== 0; // If user has no roles in this tenant, don't show the side nav + const [tenantDrawerOpen, setTenantDrawerOpen] = useState(false); + + const handleTenantDrawerOpen = (isOpen: boolean) => { + setTenantDrawerOpen(isOpen); + }; -const InternalHeader = ({ drawerWidth = 280 }: HeaderProps) => { - const isMediumScreen: boolean = useMediaQuery((theme: Theme) => theme.breakpoints.up('md')); - const [open, setOpen] = useState(false); - const tenant: TenantState = useAppSelector((state) => state.tenant); - const navigate = useNavigate(); + useEffect(() => { + if (isMediumScreenOrLarger || sideNavOpen) { + const timer = setTimeout(() => { + setSecondaryMenuOpen(true); + }, 200); // Delay to allow the sidenav to open first + return () => clearTimeout(timer); + } else { + setSecondaryMenuOpen(false); + } + }, [isMediumScreenOrLarger, sideNavOpen]); + + const { myTenants } = useRouteLoaderData('authenticated-root') as { myTenants: Tenant[] }; + + const sidePadding = { xs: '0 1em', md: '0 1.5em 0 2em', lg: '0 3em 0 2em' }; return ( - <> - + (isMediumScreen ? theme.zIndex.drawer + 1 : theme.zIndex.drawer), - backgroundColor: Palette.internalHeader.backgroundColor, - color: Palette.internalHeader.color, + '& div.PrivateSwipeArea-root': { + zIndex: (theme: Theme) => theme.zIndex.drawer + 4, + }, }} - data-testid="appbar-header" > - - - - setOpen(!open)} - > - - - - theme.zIndex.drawer + 4, // render above sidenav + backgroundColor: 'transparent', + color: Palette.internalHeader.color, + borderBottomRightRadius: '16px', + backgroundClip: 'padding-box', + overflow: 'hidden', + left: 0, + boxShadow: tenantDrawerOpen ? 'none' : elevations.default, + }} + data-testid="appbar-header" + > + + { - navigate('/home'); + > + + + + + + engage{/*no space*/} + BC + + + + + + - {isMediumScreen ? ( - { - navigate('/home'); + > + theme.zIndex.drawer + 3, // render above sidenav + background: colors.surface.blue[90], + height: '3.5em', + minHeight: 0, + justifyContent: 'space-between', + padding: sidePadding, + display: 'flex', + alignItems: 'center', }} - sx={{ flexGrow: 1, cursor: 'pointer', margin: 0 }} > - {tenant.name} - - ) : ( - { - navigate('/home'); + + + {tenant.name} + + } + > + + + + + + + + + + + + + + + + + ); +}; + +const TenantSelector = ({ + isVisible, + onStateChange, +}: { + isVisible: boolean; + onStateChange?: (isOpen: boolean) => void; +}) => { + const shouldUseMobileView = useMediaQuery((theme: Theme) => theme.breakpoints.down('md')); + const currentTenant = useAppSelector((state) => state.tenant); + const tenants = (useAsyncValue() as Tenant[]).filter((tenant) => tenant.short_name !== currentTenant.short_name); + const noOtherTenants = tenants.length === 0; + const tenantDropdownButton = useRef(null); + const [tenantDrawerOpen, setTenantDrawerOpen] = useState(false); + + useEffect(() => { + if (!isVisible) setTenantDrawerOpen(false); + }, [isVisible, setTenantDrawerOpen]); + + useEffect(() => { + if (onStateChange) onStateChange(tenantDrawerOpen); + }, [tenantDrawerOpen, onStateChange]); + + if (noOtherTenants) { + return ( + + {currentTenant.name} + + ); + } + + const tenantList = tenants.map((tenant) => ( + + {tenant.name} + + )); + + if (shouldUseMobileView) { + return ( + <> + { + setTenantDrawerOpen(!tenantDrawerOpen); + }} + sx={{ + height: 'calc( 100% - 2px )' /* 1px on either side to show border*/, + ...dropdownMenuStyles, + }} + > + + + { + setTenantDrawerOpen(false); + }} + sx={{ + zIndex: (theme: Theme) => theme.zIndex.drawer + 3, // render under app bar but above side nav + '& .MuiDrawer-paper': { + padding: '1rem', + top: '6.5rem', + backgroundImage: 'none', + borderBottomRightRadius: '16px', + }, + }} + > + + + + ); + } + + return ( + + {tenantList} + + ); +}; + +const TenantButtonContent = ({ isOpen }: { isOpen: boolean }) => { + const currentTenant = useAppSelector((state) => state.tenant); + const noOtherTenants = !(useAsyncValue() as Tenant[]).some( + (tenant) => tenant.short_name !== currentTenant.short_name, + ); + return ( + + + + {currentTenant.name} + + + + + ); +}; + +const UserMenu = () => { + const userGreeting = useRef(null); + + return ( + + { + UserService.doLogout(); + }} + sx={{ width: '100%', paddingLeft: 2, paddingRight: 2 }} + > + + Logout + + + ); +}; + +const UserButtonContent = ({ isOpen }: { isOpen: boolean }) => { + const currentUser = useAppSelector((state) => state.user.userDetail.user); + return ( + + + + {currentUser?.first_name[0]} + {currentUser?.last_name[0]} + + + + + Hello {currentUser?.first_name} + + + {currentUser?.roles.includes(USER_ROLES.SUPER_ADMIN) + ? 'Super Admin' + : currentUser?.main_role ?? 'User'} + + + + + + ); }; diff --git a/met-web/src/components/layout/SideNav/SideNav.tsx b/met-web/src/components/layout/SideNav/SideNav.tsx index 86fbaba8b..5292b002b 100644 --- a/met-web/src/components/layout/SideNav/SideNav.tsx +++ b/met-web/src/components/layout/SideNav/SideNav.tsx @@ -1,16 +1,32 @@ import React from 'react'; -import { ListItemButton, List, ListItem, Box, Drawer, Toolbar, Divider } from '@mui/material'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { + ListItemButton, + List, + ListItem, + Box, + Drawer, + Toolbar, + Divider, + SwipeableDrawer, + Grid, + Avatar, + ThemeProvider, +} from '@mui/material'; +import { useLocation } from 'react-router-dom'; import { Routes, Route } from './SideNavElements'; -import { Palette, colors } from '../../../styles/Theme'; -import { SideNavProps, DrawerBoxProps, CloseButtonProps } from './types'; -import { When, Unless } from 'react-if'; +import { DarkTheme, Palette, colors } from '../../../styles/Theme'; +import { SideNavProps, DrawerBoxProps } from './types'; +import { When } from 'react-if'; import { useAppSelector } from 'hooks'; import UserGuideNav from './UserGuideNav'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLinkSlash } from '@fortawesome/pro-regular-svg-icons/faLinkSlash'; import { faCheck } from '@fortawesome/pro-solid-svg-icons/faCheck'; -import { faArrowLeft } from '@fortawesome/pro-solid-svg-icons/faArrowLeft'; +import { Link } from 'components/common/Navigation'; +import { BodyText } from 'components/common/Typography'; +import { USER_ROLES } from 'services/userService/constants'; +import UserService from 'services/userService'; +import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; export const routeItemStyle = { padding: 0, @@ -32,34 +48,7 @@ export const routeItemStyle = { }, }; -const CloseButton = ({ setOpen }: CloseButtonProps) => { - return ( - <> - - setOpen(false)} - disableRipple - sx={{ - padding: 2, - pr: 4, - justifyContent: 'flex-end', - color: Palette.text.primary, - '&:hover, &:active, &:focus': { - backgroundColor: 'transparent', - }, - }} - > - - Close Menu - - - - - ); -}; - const DrawerBox = ({ isMediumScreenOrLarger, setOpen }: DrawerBoxProps) => { - const navigate = useNavigate(); const location = useLocation(); const permissions = useAppSelector((state) => state.user.roles); @@ -85,7 +74,7 @@ const DrawerBox = ({ isMediumScreenOrLarger, setOpen }: DrawerBoxProps) => { }} > { pl: 4, }} data-testid={`SideNav/${route.name}-button`} + to={route.path} onClick={() => { - navigate(route.path); setOpen(false); }} > @@ -148,10 +137,7 @@ const DrawerBox = ({ isMediumScreenOrLarger, setOpen }: DrawerBoxProps) => { mt: '5.625rem', }} > - - - - + {allowedRoutes.map((route) => renderListItem(route, currentBaseRoute === route.base ? 'selected' : 'other'), )} @@ -160,15 +146,18 @@ const DrawerBox = ({ isMediumScreenOrLarger, setOpen }: DrawerBoxProps) => { ); }; -const SideNav = ({ open, setOpen, isMediumScreen, drawerWidth = 300 }: SideNavProps) => { - if (!drawerWidth) return <>; +const SideNav = ({ open, setOpen, isMediumScreen }: SideNavProps) => { + const currentUser = useAppSelector((state) => state.user.userDetail.user); if (isMediumScreen) return ( @@ -186,19 +175,65 @@ const SideNav = ({ open, setOpen, isMediumScreen, drawerWidth = 300 }: SideNavPr ); return ( - theme.zIndex.drawer + 3, // render above feedback button }} + onOpen={() => setOpen(true)} onClose={() => setOpen(false)} - anchor={'left'} + anchor={'top'} open={open} - hideBackdrop={!open} + disableEnforceFocus + disablePortal > - - + + + + + + + {currentUser?.first_name[0]} + {currentUser?.last_name[0]} + + + + + Hello {currentUser?.first_name} + + + {currentUser?.roles.includes(USER_ROLES.SUPER_ADMIN) + ? 'Super Admin' + : currentUser?.main_role ?? 'User'} + + + + + Logout + + + + + + + ); }; diff --git a/met-web/src/components/layout/SideNav/types.ts b/met-web/src/components/layout/SideNav/types.ts index 2c8243747..03adf3abc 100644 --- a/met-web/src/components/layout/SideNav/types.ts +++ b/met-web/src/components/layout/SideNav/types.ts @@ -1,7 +1,6 @@ export interface SideNavProps { open: boolean; isMediumScreen: boolean; - drawerWidth: number; setOpen: (open: boolean) => void; } @@ -9,7 +8,3 @@ export interface DrawerBoxProps { isMediumScreenOrLarger: boolean; setOpen: (open: boolean) => void; } - -export interface CloseButtonProps { - setOpen: (open: boolean) => void; -} diff --git a/met-web/src/routes/AuthenticatedRootRouteLoader.tsx b/met-web/src/routes/AuthenticatedRootRouteLoader.tsx new file mode 100644 index 000000000..a57bac232 --- /dev/null +++ b/met-web/src/routes/AuthenticatedRootRouteLoader.tsx @@ -0,0 +1,8 @@ +import { defer } from 'react-router-dom'; +import { getMyTenants } from 'services/tenantService'; + +export const authenticatedRootLoader = async () => { + // Data that should be available on all authenticated pages + const myTenants = getMyTenants(); + return defer({ myTenants }); +}; diff --git a/met-web/src/services/tenantService/index.tsx b/met-web/src/services/tenantService/index.tsx index b43c3302b..80847f9c8 100644 --- a/met-web/src/services/tenantService/index.tsx +++ b/met-web/src/services/tenantService/index.tsx @@ -20,6 +20,14 @@ export const getAllTenants = async (): Promise => { return Promise.reject(Error('Failed to fetch tenants')); }; +export const getMyTenants = async (): Promise => { + const response = await http.GetRequest(Endpoints.Tenants.GET_OWN); + if (response.data) { + return response.data; + } + return Promise.reject(Error('Failed to fetch tenants')); +}; + export const createTenant = async (tenant: Tenant): Promise => { const response = await http.PostRequest(Endpoints.Tenants.CREATE, tenant); if (response.data) { diff --git a/met-web/tests/unit/components/factory.ts b/met-web/tests/unit/components/factory.ts index 64e63e3de..410506f61 100644 --- a/met-web/tests/unit/components/factory.ts +++ b/met-web/tests/unit/components/factory.ts @@ -17,6 +17,8 @@ import { VideoWidget } from 'models/videoWidget'; import { TimelineWidget, TimelineEvent, EventStatus } from 'models/timelineWidget'; import { Tenant } from 'models/tenant'; import { EngagementContent } from 'models/engagementContent'; +import { UserState } from 'services/userService/types'; +import { USER_ROLES } from 'services/userService/constants'; const tenant: Tenant = { name: 'Tenant 1', @@ -289,6 +291,32 @@ const engagementContentData: EngagementContent = { is_internal: true, }; +const staffUserState: Partial = { + userDetail: { + user: { + first_name: 'Test', + last_name: 'User', + main_role: USER_ROLES.VIEW_ENGAGEMENT, + roles: [USER_ROLES.VIEW_ENGAGEMENT, USER_ROLES.MANAGE_METADATA, USER_ROLES.VIEW_USERS], + composite_roles: [USER_ROLES.VIEW_ENGAGEMENT], + contact_number: '1234567890', + email_address: 'blah@example.com', + id: 123, + created_date: '2021-01-01T00:00:00.000Z', + description: 'Test User', + external_id: '123', + status_id: 1, + updated_date: '2021-01-01T00:00:00.000Z', + username: 'testuser', + }, + }, + authentication: { + loading: false, + authenticated: true, + }, + roles: [USER_ROLES.VIEW_ENGAGEMENT, USER_ROLES.MANAGE_METADATA, USER_ROLES.VIEW_USERS], +}; + export { tenant, draftEngagement, @@ -313,4 +341,5 @@ export { mockTimeLine, subscribeWidget, engagementContentData, + staffUserState, }; diff --git a/met-web/tests/unit/components/header.test.tsx b/met-web/tests/unit/components/header.test.tsx index 3406dd9ec..4779c60ed 100644 --- a/met-web/tests/unit/components/header.test.tsx +++ b/met-web/tests/unit/components/header.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; import '@testing-library/jest-dom'; import LoggedInHeader from '../../../src/components/layout/Header/InternalHeader'; -import { render, waitFor, screen } from '@testing-library/react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import ProviderShell from './ProviderShell'; import { setupEnv } from './setEnvVars'; +import { staffUserState, tenant } from './factory'; jest.mock('@reduxjs/toolkit/query/react', () => ({ ...jest.requireActual('@reduxjs/toolkit/query/react'), @@ -17,6 +18,27 @@ jest.mock('hooks', () => ({ t: (key: string) => key, }; }), + useAppSelector: (callback: any) => + callback({ + user: staffUserState, + tenant: tenant, + }), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteLoaderData: (routeId: string) => { + if (routeId === 'authenticated-root') { + return { + myTenants: [tenant, { ...tenant, name: 'Tenant 2', short_name: 'T2' }], + }; + } + }, +})); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + useMediaQuery: jest.fn(() => true), })); test('Load Header', async () => { @@ -27,9 +49,19 @@ test('Load Header', async () => { , ); - await waitFor(() => screen.getByTestId('button-header')); + await waitFor(() => screen.getByTestId('tenant-switcher-button')); + await waitFor(() => screen.getByTestId('user-menu-button')); + + expect(screen.getByTestId('tenant-switcher-button')).toHaveTextContent('Tenant 1'); + expect(screen.getByTestId('user-menu-button')).toHaveTextContent('Hello Test'); + + fireEvent.click(screen.getByTestId('tenant-switcher-button')); + + // Wait for the tenant switcher to open + await waitFor(() => screen.getByText('Tenant 2')); - expect(screen.getByTestId('button-header')).toHaveTextContent('Logout'); + fireEvent.click(screen.getByTestId('user-menu-button')); - expect(screen.getByTestId('button-header')).not.toBeDisabled(); + // Wait for the user menu to open + await waitFor(() => screen.getByText('Logout')); }); diff --git a/met-web/tests/unit/components/sidenav.test.tsx b/met-web/tests/unit/components/sidenav.test.tsx index 39db8bd42..89251fade 100644 --- a/met-web/tests/unit/components/sidenav.test.tsx +++ b/met-web/tests/unit/components/sidenav.test.tsx @@ -6,8 +6,8 @@ import ProviderShell from './ProviderShell'; import { setupEnv } from './setEnvVars'; import { Routes } from '../../../src/components/layout/SideNav/SideNavElements'; import { USER_ROLES } from 'services/userService/constants'; - -const drawerWidth = 280; +import { UserState } from 'services/userService/types'; +import { staffUserState } from './factory'; jest.mock('@reduxjs/toolkit/query/react', () => ({ ...jest.requireActual('@reduxjs/toolkit/query/react'), @@ -16,26 +16,33 @@ jest.mock('@reduxjs/toolkit/query/react', () => ({ jest.mock('axios'); -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(() => { - return [ - USER_ROLES.VIEW_ENGAGEMENT, - USER_ROLES.VIEW_ASSIGNED_ENGAGEMENTS, - USER_ROLES.VIEW_SURVEYS, - USER_ROLES.VIEW_USERS, - USER_ROLES.VIEW_FEEDBACKS, - USER_ROLES.SUPER_ADMIN, - USER_ROLES.MANAGE_METADATA, - USER_ROLES.VIEW_LANGUAGES, - ]; +jest.mock('hooks', () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, // return the key itself (i.e. no translation) }), + useAppSelector: (callback: any) => + callback({ + user: { + ...staffUserState, + roles: [ + USER_ROLES.VIEW_ENGAGEMENT, + USER_ROLES.VIEW_ASSIGNED_ENGAGEMENTS, + USER_ROLES.VIEW_SURVEYS, + USER_ROLES.VIEW_USERS, + USER_ROLES.VIEW_FEEDBACKS, + USER_ROLES.SUPER_ADMIN, + USER_ROLES.MANAGE_METADATA, + USER_ROLES.VIEW_LANGUAGES, + ], + } as UserState, + }), })); + test('Load SideNav', async () => { setupEnv(); render( - void 0} isMediumScreen={false} open={true} drawerWidth={drawerWidth} /> + void 0} isMediumScreen={false} open={true} /> , );