Skip to content

Commit

Permalink
Merge pull request #2569 from bcgov/feature/DESENG-660-admin-header-r…
Browse files Browse the repository at this point in the history
…edesign

[To Main] DESENG-660: Admin Header Redesign
  • Loading branch information
NatSquared authored Jul 31, 2024
2 parents 2cd56b4 + 55c2c7a commit 48adce8
Show file tree
Hide file tree
Showing 18 changed files with 825 additions and 177 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
10 changes: 9 additions & 1 deletion met-api/src/met_api/models/user_group_membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from flask import g
from sqlalchemy import PrimaryKeyConstraint

from .base_model import BaseModel
from .db import db

Expand All @@ -21,19 +22,26 @@ 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__ = (
PrimaryKeyConstraint('id', 'staff_user_external_id', 'tenant_id'),
)

@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."""
Expand Down
42 changes: 41 additions & 1 deletion met-api/src/met_api/resources/staff_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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('/<user_id>/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
9 changes: 7 additions & 2 deletions met-api/src/met_api/services/user_group_membership_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand Down
12 changes: 8 additions & 4 deletions met-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -186,7 +186,7 @@ const App = () => {
loadTranslation();
}, [language.id, translations]);

if (authenticationLoading || tenant.loading) {
if (authenticationLoading || tenant.loading || !tenant.short_name) {
return <MidScreenLoader />;
}

Expand Down Expand Up @@ -222,13 +222,15 @@ const App = () => {
const router = createBrowserRouter(
[
{
element: <AuthenticatedLayout drawerWidth={0} />,
element: <AuthenticatedLayout />,
children: [
{
path: '*',
element: <NoAccess />,
},
],
loader: authenticatedRootLoader,
id: 'authenticated-root',
},
],
{ basename: `/${basename}` },
Expand All @@ -240,11 +242,13 @@ const App = () => {
const router = createBrowserRouter(
[
{
element: <AuthenticatedLayout drawerWidth={drawerWidth} />,
element: <AuthenticatedLayout />,
children: createRoutesFromElements(AuthenticatedRoutes()),
handle: {
crumb: () => ({ name: 'Dashboard', link: '/home' }),
},
id: 'authenticated-root',
loader: authenticatedRootLoader,
},
],
{ basename: `/${basename}` },
Expand Down
3 changes: 2 additions & 1 deletion met-web/src/apiManager/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
},
Expand Down
6 changes: 3 additions & 3 deletions met-web/src/components/appLayouts/AuthenticatedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<DocumentTitle />
<Box sx={{ display: 'flex' }}>
<InternalHeader drawerWidth={drawerWidth} />
<InternalHeader />
<Notification />
<NotificationModal />
<Box component="main" sx={{ flexGrow: 1, marginTop: '80px' }}>
<Box component="main" sx={{ flexGrow: 1, marginTop: { xs: '3.5em', md: '6.5em' } }}>
<ScrollToTop />
<FormioListener />
<LocalizationProvider
Expand Down
3 changes: 1 addition & 2 deletions met-web/src/components/common/Layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ export const ResponsiveContainer: React.FC<BoxProps> = (props: BoxProps) => {
<Box
{...props}
sx={{
marginTop: '1em',
padding: { xs: '1.5em 1em', md: '1.5em 1.5em', lg: '1.5em 2em' },
padding: { xs: '2em 1em', md: '2em 1.5em', lg: '2em 3em' },
...props.sx,
}}
>
Expand Down
139 changes: 139 additions & 0 deletions met-web/src/components/common/Navigation/DropdownMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 <nav>{children}</nav>;
}
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<PopperProps>;
children?: React.ReactNode;
} & MenuListProps) => {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownId = useId();

return (
<>
{/* Dropdown Button */}
<ButtonBase
ref={buttonRef}
aria-haspopup
aria-controls={open ? dropdownId : undefined}
aria-expanded={open}
aria-label={name || 'Dropdown Menu'}
onClick={() => {
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 */}
<Unless condition={buttonContent === undefined}>{buttonContent?.({ isOpen: open })}</Unless>
{/* A basic button label if no custom content is provided */}
<When condition={buttonContent === undefined}>
<Grid container direction="row" alignItems="center" spacing={1}>
<Grid item>
<BodyText sx={{ userSelect: 'none', textTransform: 'capitalize' }}>{name}</BodyText>
</Grid>
<Grid item hidden={!children}>
<FontAwesomeIcon color="white" icon={faChevronDown} rotation={open ? 180 : undefined} />
</Grid>
</Grid>
</When>
</ButtonBase>
<ClickAwayListener
mouseEvent="onMouseUp"
onClickAway={(e) => {
if (e.target !== buttonRef.current && !buttonRef.current?.contains(e.target as Node))
setOpen(false);
}}
>
{/* Dropdown Contents */}
<Popper
onKeyDown={(e) => {
// 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,
}}
>
<ToggleNav isNav={forNavigation}>
<TrapFocus open={open}>
<MenuList
sx={{ minWidth: 'fit-content' }}
aria-label={name || 'Dropdown Menu'}
tabIndex={-1}
aria-expanded={open}
autoFocusItem
{...props}
>
{children}
</MenuList>
</TrapFocus>
</ToggleNav>
</Popper>
</ClickAwayListener>
</>
);
};
export default DropdownMenu;
4 changes: 3 additions & 1 deletion met-web/src/components/engagement/listing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,9 @@ const EngagementListing = () => {
columnSpacing={2}
rowSpacing={1}
>
<AutoBreadcrumbs />
<Grid item>
<AutoBreadcrumbs />
</Grid>
<Grid item xs={12}>
<Stack
direction={{ xs: 'column', md: 'row' }}
Expand Down
Loading

0 comments on commit 48adce8

Please sign in to comment.