From 97eb775a51e5dbc93025ba1fe3829031c1999412 Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Tue, 7 Jul 2026 17:39:57 +0500 Subject: [PATCH 1/3] feat(admin-ui): implement bottom navigation bar for the mobile view (#2917) Signed-off-by: faisalsiddique4400 --- .../MobileBottomNav/MobileBottomNav.style.ts | 79 +++++ .../MobileBottomNav/MobileBottomNav.tsx | 129 ++++++++ .../MobileBottomNav/MobileNavSheet.style.ts | 218 ++++++++++++++ .../MobileBottomNav/MobileNavSheet.tsx | 276 ++++++++++++++++++ .../__tests__/MobileBottomNav.test.tsx | 153 ++++++++++ .../__tests__/MobileNavSheet.test.tsx | 184 ++++++++++++ .../__tests__/sheetConstants.test.ts | 85 ++++++ .../__tests__/sheetIcons.test.tsx | 25 ++ .../components/MobileBottomNav/constants.ts | 44 +++ .../MobileBottomNav/sheetConstants.ts | 169 +++++++++++ .../components/MobileBottomNav/sheetIcons.tsx | 28 ++ .../app/components/MobileBottomNav/types.ts | 30 ++ admin-ui/app/constants/ui.ts | 1 + admin-ui/app/customColors.ts | 9 + admin-ui/app/index.tsx | 13 - .../app/layout/__tests__/default.test.tsx | 7 + admin-ui/app/layout/default.tsx | 5 + admin-ui/app/locales/en/translation.json | 5 +- admin-ui/app/locales/es/translation.json | 5 +- admin-ui/app/locales/fr/translation.json | 5 +- admin-ui/app/locales/pt/translation.json | 5 +- .../app/styles/miltonbo/scss/_layout.scss | 13 + 22 files changed, 1471 insertions(+), 17 deletions(-) create mode 100644 admin-ui/app/components/MobileBottomNav/MobileBottomNav.style.ts create mode 100644 admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx create mode 100644 admin-ui/app/components/MobileBottomNav/MobileNavSheet.style.ts create mode 100644 admin-ui/app/components/MobileBottomNav/MobileNavSheet.tsx create mode 100644 admin-ui/app/components/MobileBottomNav/__tests__/MobileBottomNav.test.tsx create mode 100644 admin-ui/app/components/MobileBottomNav/__tests__/MobileNavSheet.test.tsx create mode 100644 admin-ui/app/components/MobileBottomNav/__tests__/sheetConstants.test.ts create mode 100644 admin-ui/app/components/MobileBottomNav/__tests__/sheetIcons.test.tsx create mode 100644 admin-ui/app/components/MobileBottomNav/constants.ts create mode 100644 admin-ui/app/components/MobileBottomNav/sheetConstants.ts create mode 100644 admin-ui/app/components/MobileBottomNav/sheetIcons.tsx create mode 100644 admin-ui/app/components/MobileBottomNav/types.ts diff --git a/admin-ui/app/components/MobileBottomNav/MobileBottomNav.style.ts b/admin-ui/app/components/MobileBottomNav/MobileBottomNav.style.ts new file mode 100644 index 000000000..087b7ebac --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/MobileBottomNav.style.ts @@ -0,0 +1,79 @@ +import { makeStyles } from 'tss-react/mui' +import { hexToRgb } from '@/customColors' +import { fontFamily, fontWeights, fontSizes } from '@/styles/fonts' +import { BOTTOM_NAV } from './constants' +import { SHEET } from './sheetConstants' +import type { MobileBottomNavThemeColors } from './types' + +const useStyles = makeStyles<{ colors: MobileBottomNavThemeColors }>()((_theme, { colors }) => ({ + root: { + position: 'fixed', + left: 0, + right: 0, + bottom: 0, + zIndex: BOTTOM_NAV.Z_ROOT, + display: 'flex', + alignItems: 'stretch', + height: BOTTOM_NAV.HEIGHT, + padding: `0 ${BOTTOM_NAV.PADDING_X}px`, + backgroundColor: colors.background, + borderTop: `1px solid ${colors.border}`, + boxShadow: `0px ${BOTTOM_NAV.SHADOW_OFFSET_Y}px ${BOTTOM_NAV.SHADOW_BLUR}px 0px rgba(${hexToRgb(colors.shadow)}, ${BOTTOM_NAV.SHADOW_OPACITY})`, + paddingBottom: 'env(safe-area-inset-bottom)', + boxSizing: 'border-box', + }, + rootElevated: { + zIndex: SHEET.Z_BAR_ELEVATED, + boxShadow: 'none', + }, + tab: { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: BOTTOM_NAV.TAB_GAP, + minWidth: 0, + padding: 0, + border: 'none', + background: 'transparent', + cursor: 'pointer', + position: 'relative', + color: colors.inactive, + minHeight: BOTTOM_NAV.TAB_MIN_HEIGHT, + }, + tabActive: { + color: colors.active, + }, + iconWrap: { + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'width': BOTTOM_NAV.ICON_SIZE, + 'height': BOTTOM_NAV.ICON_SIZE, + '& svg': { + width: BOTTOM_NAV.ICON_SIZE, + height: BOTTOM_NAV.ICON_SIZE, + }, + }, + label: { + fontFamily, + fontSize: fontSizes.xs, + fontWeight: fontWeights.medium, + lineHeight: 1, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '100%', + }, + activeIndicator: { + position: 'absolute', + bottom: 0, + width: BOTTOM_NAV.ACTIVE_INDICATOR_WIDTH, + height: BOTTOM_NAV.ACTIVE_INDICATOR_HEIGHT, + borderRadius: BOTTOM_NAV.ACTIVE_INDICATOR_HEIGHT, + backgroundColor: colors.active, + }, +})) + +export { useStyles } diff --git a/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx b/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx new file mode 100644 index 000000000..5901a8778 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx @@ -0,0 +1,129 @@ +import { useCallback, useMemo, useState, type JSX } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router-dom' +import useMediaQuery from '@mui/material/useMediaQuery' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import { useAppNavigation } from '@/helpers/navigation' +import customColors from '@/customColors' +import { useTheme } from '@/context/theme/themeContext' +import getThemeColor from '@/context/theme/config' +import { THEME_LIGHT } from '@/context/theme/constants' +import { HomeIcon, OAuthIcon, UsersIcon } from '../SVG' +import { useStyles } from './MobileBottomNav.style' +import { MOBILE_MEDIA_QUERY, PRIMARY_TAB_DEFS, MORE_TAB_KEY } from './constants' +import MobileNavSheet from './MobileNavSheet' +import { isMoreMenuPath, type SheetItem, type SheetKey } from './sheetConstants' +import type { MobileNavTab, MobileBottomNavThemeColors } from './types' + +const ICON_BY_KEY: Record = { + home: , + oauthserver: , + usersmanagement: , +} + +const MobileBottomNav = (): JSX.Element | null => { + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) + const { t } = useTranslation() + const { navigateToRoute } = useAppNavigation() + const location = useLocation() + const { state } = useTheme() + const [openSheet, setOpenSheet] = useState(null) + + const themeColors = useMemo((): MobileBottomNavThemeColors => { + const isLight = state.theme === THEME_LIGHT + const theme = getThemeColor(state.theme) + return { + background: isLight ? customColors.white : theme.navbar.background, + shadow: customColors.black, + border: theme.navbar.border, + active: customColors.mobileNavActive, + inactive: isLight ? customColors.mobileNavInactiveLight : customColors.mobileNavInactiveDark, + } + }, [state.theme]) + + const { classes, cx } = useStyles({ colors: themeColors }) + + const tabs = useMemo((): MobileNavTab[] => { + const primary: MobileNavTab[] = PRIMARY_TAB_DEFS.map((def) => ({ + key: def.key, + titleKey: def.titleKey, + path: def.path, + basePath: def.basePath, + directNav: 'directNav' in def ? def.directNav : undefined, + icon: ICON_BY_KEY[def.iconKey], + })) + return [ + ...primary, + { + key: MORE_TAB_KEY, + titleKey: 'menus.more', + isMore: true, + icon: , + }, + ] + }, []) + + const isTabActive = (tab: MobileNavTab): boolean => { + if (openSheet) return openSheet === tab.key + if (isMoreMenuPath(location.pathname)) return tab.isMore === true + if (tab.isMore) return false + const matchPath = tab.basePath ?? tab.path + if (!matchPath) return false + return location.pathname === matchPath || location.pathname.startsWith(`${matchPath}/`) + } + + const handleTabClick = (tab: MobileNavTab): void => { + if (tab.directNav && tab.path) { + setOpenSheet(null) + navigateToRoute(tab.path) + return + } + setOpenSheet((current) => (current === tab.key ? null : (tab.key as SheetKey))) + } + + const handleSheetClose = useCallback((): void => { + setOpenSheet(null) + }, []) + + const handleSheetSelect = useCallback( + (item: SheetItem): void => { + if (!item.path) return + setOpenSheet(null) + navigateToRoute(item.path) + }, + [navigateToRoute], + ) + + if (!isMobile) return null + + return ( + <> + + + + ) +} + +export default MobileBottomNav diff --git a/admin-ui/app/components/MobileBottomNav/MobileNavSheet.style.ts b/admin-ui/app/components/MobileBottomNav/MobileNavSheet.style.ts new file mode 100644 index 000000000..2f448d24a --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/MobileNavSheet.style.ts @@ -0,0 +1,218 @@ +import { makeStyles } from 'tss-react/mui' +import customColors, { hexToRgb } from '@/customColors' +import { BORDER_RADIUS } from '@/constants/ui' +import { fontFamily, fontSizes, fontWeights } from '@/styles/fonts' +import { BOTTOM_NAV } from './constants' +import { SHEET } from './sheetConstants' +import type { MobileNavSheetThemeColors } from './types' + +const useStyles = makeStyles<{ colors: MobileNavSheetThemeColors }>()((_theme, { colors }) => ({ + scrim: { + position: 'fixed', + inset: 0, + zIndex: SHEET.Z_SCRIM, + border: 'none', + padding: 0, + cursor: 'pointer', + backgroundColor: `rgba(${hexToRgb(customColors.black)}, ${SHEET.SCRIM_OPACITY})`, + }, + sheet: { + position: 'fixed', + left: 0, + right: 0, + bottom: 0, + zIndex: SHEET.Z_SHEET, + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + maxHeight: SHEET.MAX_HEIGHT, + backgroundColor: colors.background, + borderRadius: `${BORDER_RADIUS.MOBILE_SHEET}px ${BORDER_RADIUS.MOBILE_SHEET}px 0 0`, + boxShadow: `0px ${SHEET.SHADOW_OFFSET_Y}px ${SHEET.SHADOW_BLUR}px 0px rgba(${hexToRgb(customColors.black)}, ${SHEET.SHADOW_OPACITY})`, + paddingBottom: BOTTOM_NAV.HEIGHT, + }, + header: { + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: SHEET.HEADER_GAP, + padding: SHEET.HEADER_PADDING, + borderBottom: `1px solid ${colors.border}`, + }, + headerIcon: { + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'width': SHEET.HEADER_ICON_SIZE, + 'height': SHEET.HEADER_ICON_SIZE, + 'color': colors.text, + '& svg': { + width: SHEET.HEADER_ICON_SIZE, + height: SHEET.HEADER_ICON_SIZE, + }, + }, + headerTitle: { + flex: 1, + fontFamily, + fontSize: fontSizes.md, + fontWeight: fontWeights.medium, + fontStyle: 'normal', + lineHeight: 'normal', + letterSpacing: '0.32px', + color: colors.text, + }, + headerTitleMuted: { + color: colors.title, + }, + close: { + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'width': SHEET.CLOSE_SIZE, + 'height': SHEET.CLOSE_SIZE, + 'padding': 0, + 'border': 'none', + 'background': 'transparent', + 'cursor': 'pointer', + 'color': colors.text, + '& svg': { + width: SHEET.CLOSE_SIZE, + height: SHEET.CLOSE_SIZE, + }, + }, + back: { + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'width': SHEET.BACK_SIZE, + 'height': SHEET.BACK_SIZE, + 'flexShrink': 0, + 'padding': 0, + 'border': 'none', + 'borderRadius': '50%', + 'cursor': 'pointer', + 'backgroundColor': colors.chip, + 'color': colors.text, + '& svg': { + width: SHEET.BACK_ICON_SIZE, + height: SHEET.BACK_ICON_SIZE, + }, + }, + body: { + flex: 1, + minHeight: 0, + overflowY: 'auto', + WebkitOverflowScrolling: 'touch', + }, + grid: { + display: 'grid', + gridTemplateColumns: `repeat(${SHEET.GRID_COLUMNS}, 1fr)`, + rowGap: SHEET.GRID_ROW_GAP, + padding: SHEET.GRID_PADDING, + }, + tile: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: SHEET.TILE_GAP, + padding: 0, + border: 'none', + background: 'transparent', + cursor: 'pointer', + minWidth: 0, + color: colors.text, + }, + tileChip: { + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'width': SHEET.TILE_CHIP_WIDTH, + 'height': SHEET.TILE_CHIP_HEIGHT, + 'borderRadius': SHEET.TILE_CHIP_RADIUS, + 'backgroundColor': colors.chip, + 'color': colors.text, + '& svg': { + width: SHEET.TILE_ICON_SIZE, + height: SHEET.TILE_ICON_SIZE, + }, + }, + tileChipActive: { + backgroundColor: colors.active, + color: colors.white, + }, + tileLabel: { + fontFamily, + fontSize: fontSizes.sm, + fontWeight: fontWeights.medium, + letterSpacing: '0.24px', + color: colors.text, + textAlign: 'center', + lineHeight: 1.2, + }, + tileLabelActive: { + color: colors.active, + }, + list: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: SHEET.LIST_ROW_GAP, + padding: `${SHEET.LIST_PADDING_Y}px ${SHEET.LIST_PADDING_X}px`, + }, + listItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: SHEET.LIST_ITEM_GAP, + padding: 0, + border: 'none', + background: 'transparent', + cursor: 'pointer', + width: '100%', + textAlign: 'left', + fontFamily, + fontSize: fontSizes.base, + fontWeight: fontWeights.medium, + letterSpacing: '0.28px', + color: colors.listText, + }, + listItemActive: { + fontWeight: fontWeights.semiBold, + color: colors.listText, + }, + listGroup: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: SHEET.LIST_ROW_GAP, + width: '100%', + }, + chevron: { + flexShrink: 0, + color: colors.listText, + transition: 'transform 0.15s ease', + }, + subList: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: SHEET.LIST_ROW_GAP, + width: '100%', + paddingLeft: SHEET.SUBLIST_INDENT, + }, + subListItem: { + padding: 0, + border: 'none', + background: 'transparent', + cursor: 'pointer', + width: '100%', + textAlign: 'left', + fontFamily, + fontSize: fontSizes.base, + fontWeight: fontWeights.medium, + letterSpacing: '0.28px', + color: colors.listText, + }, +})) + +export { useStyles } diff --git a/admin-ui/app/components/MobileBottomNav/MobileNavSheet.tsx b/admin-ui/app/components/MobileBottomNav/MobileNavSheet.tsx new file mode 100644 index 000000000..8a90d9afc --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/MobileNavSheet.tsx @@ -0,0 +1,276 @@ +import { useCallback, useEffect, useMemo, useState, type JSX } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router-dom' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { Close } from '@/components/icons' +import customColors from '@/customColors' +import { useTheme } from '@/context/theme/themeContext' +import getThemeColor from '@/context/theme/config' +import { THEME_LIGHT } from '@/context/theme/constants' +import { ChevronIcon } from '../SVG' +import { useStyles } from './MobileNavSheet.style' +import { SHEET_ICON_BY_KEY } from './sheetIcons' +import { + MORE_TILE_DEFS, + SECTION_MENUS, + SHEET_KEYS, + type SheetItem, + type SheetKey, +} from './sheetConstants' +import type { MobileNavSheetThemeColors } from './types' + +type MobileNavSheetProps = { + openKey: SheetKey | null + onClose: () => void + onSelect: (item: SheetItem) => void +} + +const MobileNavSheet = ({ + openKey, + onClose, + onSelect, +}: MobileNavSheetProps): JSX.Element | null => { + const { t } = useTranslation() + const { state } = useTheme() + const location = useLocation() + const isLight = state.theme === THEME_LIGHT + const [expandedKeys, setExpandedKeys] = useState([]) + const [drillTile, setDrillTile] = useState(null) + + const toggleExpanded = useCallback((key: string): void => { + setExpandedKeys((keys) => (keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key])) + }, []) + + const themeColors = useMemo((): MobileNavSheetThemeColors => { + const theme = getThemeColor(state.theme) + return { + background: isLight ? customColors.white : theme.navbar.background, + border: theme.navbar.border, + text: theme.fontColor, + listText: isLight ? customColors.textSecondary : customColors.white, + title: isLight ? theme.fontColor : customColors.mobileSheetTitleDark, + chip: isLight ? customColors.mobileSheetTileChipLight : customColors.mobileSheetTileChipDark, + active: customColors.mobileNavActive, + white: customColors.white, + } + }, [state.theme, isLight]) + + const { classes, cx } = useStyles({ colors: themeColors }) + + useEffect(() => { + if (!openKey) return undefined + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [openKey, onClose]) + + const isItemActive = useCallback( + (item: SheetItem): boolean => + !!item.path && + (location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)), + [location.pathname], + ) + + useEffect(() => { + if (!openKey) { + setExpandedKeys([]) + setDrillTile(null) + return + } + if (openKey === SHEET_KEYS.MORE) { + setExpandedKeys([]) + const activeTile = MORE_TILE_DEFS.find((tile) => + tile.children?.some((child) => isItemActive(child)), + ) + setDrillTile(activeTile ?? null) + return + } + setDrillTile(null) + const menu = SECTION_MENUS[openKey as 'home' | 'auth-server' | 'users'] + const activeGroups = (menu?.items ?? []) + .filter((item) => item.children?.some((child) => isItemActive(child))) + .map((item) => item.key) + setExpandedKeys(activeGroups) + }, [openKey, isItemActive]) + + if (!openKey || typeof document === 'undefined') return null + + const isMore = openKey === SHEET_KEYS.MORE + const section = isMore ? null : SECTION_MENUS[openKey as 'home' | 'auth-server' | 'users'] + const isDrilled = isMore && drillTile !== null + + const headerTitleKey = isDrilled + ? (drillTile?.titleKey ?? '') + : isMore + ? 'menus.allCategory' + : (section?.titleKey ?? '') + const headerIcon = isDrilled + ? SHEET_ICON_BY_KEY[drillTile?.iconKey ?? ''] + : isMore + ? null + : SHEET_ICON_BY_KEY[section?.iconKey ?? ''] + + const handleTileClick = (tile: SheetItem): void => { + if (tile.children?.length) { + setDrillTile(tile) + return + } + if (!tile.path) return + onSelect(tile) + } + + return createPortal( + <> + + ) : null} + {headerIcon ? {headerIcon} : null} + + {t(headerTitleKey)} + + + + +
+ {isDrilled ? ( +
+ {drillTile?.children?.map((child) => { + const childActive = isItemActive(child) + return ( + + ) + })} +
+ ) : isMore ? ( +
+ {MORE_TILE_DEFS.map((tile) => { + const active = + isItemActive(tile) || !!tile.children?.some((child) => isItemActive(child)) + return ( + + ) + })} +
+ ) : ( +
+ {section?.items.map((item) => { + if (item.children?.length) { + const expanded = expandedKeys.includes(item.key) + return ( +
+ + {expanded ? ( +
+ {item.children.map((child) => { + const childActive = isItemActive(child) + return ( + + ) + })} +
+ ) : null} +
+ ) + } + + const active = isItemActive(item) + return ( + + ) + })} +
+ )} +
+ + , + document.body, + ) +} + +export default MobileNavSheet diff --git a/admin-ui/app/components/MobileBottomNav/__tests__/MobileBottomNav.test.tsx b/admin-ui/app/components/MobileBottomNav/__tests__/MobileBottomNav.test.tsx new file mode 100644 index 000000000..0f137d3ab --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/__tests__/MobileBottomNav.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, fireEvent } from '@testing-library/react' + +let mockIsMobile = true +jest.mock('@mui/material/useMediaQuery', () => ({ + __esModule: true, + default: () => mockIsMobile, +})) + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +jest.mock('@/context/theme/themeContext', () => ({ + useTheme: () => ({ state: { theme: 'light' } }), +})) +jest.mock('@/context/theme/config', () => ({ + __esModule: true, + default: () => ({ navbar: { background: '#fff', border: '#eee' }, textMuted: '#888' }), +})) + +const mockNavigate = jest.fn() +jest.mock('@/helpers/navigation', () => ({ + useAppNavigation: () => ({ navigateToRoute: mockNavigate }), + ROUTES: { + HOME_DASHBOARD: '/home/dashboard', + AUTH_SERVER_CLIENTS_LIST: '/auth-server/clients', + USER_MANAGEMENT: '/user/usersmanagement', + CUSTOM_SCRIPT_LIST: '/home/scripts', + ATTRIBUTES_LIST: '/attributes', + SERVICES_CACHE: '/config/cache', + SERVICES_PERSISTENCE: '/config/persistence', + SMTP_BASE: '/smtp/smtpmanagement', + SCIM_BASE: '/scim', + FIDO_BASE: '/fido/configuration', + FIDO_METRICS: '/fido/metrics', + JANS_LOCK_BASE: '/jans-lock', + PLUGIN_BASE_PATHS: { + HOME: '/home', + AUTH_SERVER: '/auth-server', + USER_MANAGEMENT: '/user', + }, + }, +})) + +let mockPathname = '/home/dashboard' +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ pathname: mockPathname }), +})) + +jest.mock('../MobileNavSheet', () => ({ + __esModule: true, + default: ({ openKey }: { openKey: string | null }) => + openKey ?
{openKey}
: null, +})) + +import MobileBottomNav from '../MobileBottomNav' + +beforeEach(() => { + mockIsMobile = true + mockPathname = '/home/dashboard' + mockNavigate.mockReset() +}) + +describe('MobileBottomNav', () => { + it('renders nothing on non-mobile viewports', () => { + mockIsMobile = false + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders the four tabs on mobile', () => { + render() + expect(screen.getByTestId('mobile-bottom-nav')).toBeInTheDocument() + for (const key of ['menus.home', 'menus.oauthserver', 'menus.users', 'menus.more']) { + expect(screen.getByRole('button', { name: key })).toBeInTheDocument() + } + }) + + it('opens the matching sheet on tab tap instead of navigating directly', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'menus.oauthserver' })) + expect(screen.getByTestId('sheet-open')).toHaveTextContent('auth-server') + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('opens the More (All Category) sheet when the More tab is tapped', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'menus.more' })) + expect(screen.getByTestId('sheet-open')).toHaveTextContent('more') + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('marks the tab whose sheet is open as active, suppressing route highlight', () => { + render() + const more = screen.getByRole('button', { name: 'menus.more' }) + expect(more).not.toHaveAttribute('aria-current') + fireEvent.click(more) + expect(more).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('button', { name: 'menus.home' })).not.toHaveAttribute('aria-current') + }) + + it('toggles a sheet closed when its own tab is tapped again', () => { + render() + const auth = screen.getByRole('button', { name: 'menus.oauthserver' }) + fireEvent.click(auth) + expect(screen.getByTestId('sheet-open')).toBeInTheDocument() + fireEvent.click(auth) + expect(screen.queryByTestId('sheet-open')).not.toBeInTheDocument() + }) + + it('navigates directly (no sheet) when the Users tab is tapped', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'menus.users' })) + expect(mockNavigate).toHaveBeenCalledWith('/user/usersmanagement') + expect(screen.queryByTestId('sheet-open')).not.toBeInTheDocument() + }) + + it('keeps Auth Server active across any /auth-server sub-route (not just clients)', () => { + mockPathname = '/auth-server/scopes' + render() + expect(screen.getByRole('button', { name: 'menus.oauthserver' })).toHaveAttribute( + 'aria-current', + 'page', + ) + }) + + it('marks More active (not Home) when on a More-menu route under /home', () => { + mockPathname = '/home/scripts' + render() + expect(screen.getByRole('button', { name: 'menus.more' })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.getByRole('button', { name: 'menus.home' })).not.toHaveAttribute('aria-current') + }) + + it('marks More active on a drilled More-menu route (FIDO metrics)', () => { + mockPathname = '/fido/metrics' + render() + expect(screen.getByRole('button', { name: 'menus.more' })).toHaveAttribute( + 'aria-current', + 'page', + ) + }) + + it('marks the tab matching the current route as active when idle', () => { + mockPathname = '/auth-server/clients/edit/123' + render() + expect(screen.getByRole('button', { name: 'menus.oauthserver' })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.getByRole('button', { name: 'menus.home' })).not.toHaveAttribute('aria-current') + }) +}) diff --git a/admin-ui/app/components/MobileBottomNav/__tests__/MobileNavSheet.test.tsx b/admin-ui/app/components/MobileBottomNav/__tests__/MobileNavSheet.test.tsx new file mode 100644 index 000000000..a47b43859 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/__tests__/MobileNavSheet.test.tsx @@ -0,0 +1,184 @@ +import { render, screen, fireEvent } from '@testing-library/react' + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +jest.mock('@/context/theme/themeContext', () => ({ + useTheme: () => ({ state: { theme: 'light' } }), +})) +jest.mock('@/context/theme/config', () => ({ + __esModule: true, + default: () => ({ + navbar: { background: '#fff', border: '#eee' }, + fontColor: '#0a2540', + textMuted: '#425466', + lightBackground: '#f5f5f5', + }), +})) + +let mockPathname = '/home/dashboard' +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ pathname: mockPathname }), +})) + +jest.mock('../sheetIcons', () => ({ + SHEET_ICON_BY_KEY: new Proxy( + {}, + { get: (_t, key) => }, + ), +})) + +import MobileNavSheet from '../MobileNavSheet' + +const noop = () => {} + +beforeEach(() => { + mockPathname = '/home/dashboard' +}) + +describe('MobileNavSheet', () => { + it('renders nothing when no sheet is open', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders the All Category grid with the 8 tiles for the More sheet', () => { + render() + expect(screen.getByTestId('mobile-nav-sheet-title')).toHaveTextContent('menus.allCategory') + for (const label of [ + 'menus.scripts', + 'menus.user_claims', + 'menus.services', + 'menus.smtp', + 'menus.scim', + 'menus.fido', + 'menus.jans_lock', + 'menus.notification', + ]) { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument() + } + }) + + it('drills a tile with children (FIDO) into its sub-list instead of navigating', () => { + const onSelect = jest.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'menus.fido' })) + expect(screen.getByTestId('mobile-nav-sheet-title')).toHaveTextContent('menus.fido') + expect(screen.getByRole('button', { name: 'menus.configuration' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'menus.metrics' })).toBeInTheDocument() + expect(onSelect).not.toHaveBeenCalled() + }) + + it('navigates on a drilled child tap and offers a back button to the grid', () => { + const onSelect = jest.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'menus.services' })) + const back = screen.getByRole('button', { name: 'actions.back' }) + fireEvent.click(back) + expect(screen.getByTestId('mobile-nav-sheet-title')).toHaveTextContent('menus.allCategory') + fireEvent.click(screen.getByRole('button', { name: 'menus.services' })) + fireEvent.click(screen.getByRole('button', { name: 'menus.cache' })) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ path: '/config/cache' })) + }) + + it('opens the More sheet pre-drilled into the tile holding the current route', () => { + mockPathname = '/fido/metrics' + render() + expect(screen.getByTestId('mobile-nav-sheet-title')).toHaveTextContent('menus.fido') + expect(screen.getByRole('button', { name: 'menus.metrics' })).toHaveAttribute( + 'aria-current', + 'page', + ) + }) + + it('marks the grid tile active when one of its child routes is current', () => { + mockPathname = '/config/persistence' + render() + fireEvent.click(screen.getByRole('button', { name: 'actions.back' })) + expect(screen.getByRole('button', { name: 'menus.services' })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.getByRole('button', { name: 'menus.scim' })).not.toHaveAttribute('aria-current') + }) + + it('renders the section list for a primary tab and marks the active route bold', () => { + mockPathname = '/home/dashboard' + render() + expect(screen.getByTestId('mobile-nav-sheet-title')).toHaveTextContent('menus.home') + expect(screen.getByRole('button', { name: 'menus.dashboard' })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.getByRole('button', { name: 'menus.health' })).not.toHaveAttribute('aria-current') + }) + + it('calls onSelect with the tapped item', () => { + const onSelect = jest.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'menus.health' })) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ path: '/home/health' })) + }) + + it('renders Security as a collapsed expandable row (children hidden, does not navigate)', () => { + const onSelect = jest.fn() + render() + const security = screen.getByRole('button', { name: /menus.security/ }) + expect(security).toHaveAttribute('aria-expanded', 'false') + expect( + screen.queryByRole('button', { name: 'menus.securityDropdown.mapping' }), + ).not.toBeInTheDocument() + fireEvent.click(security) + expect(security).toHaveAttribute('aria-expanded', 'true') + expect(onSelect).not.toHaveBeenCalled() + }) + + it('reveals Security children on expand and selects a child on tap', () => { + const onSelect = jest.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /menus.security/ })) + const child = screen.getByRole('button', { name: 'menus.securityDropdown.mapping' }) + expect(child).toBeInTheDocument() + fireEvent.click(child) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ path: '/home/mapping' })) + }) + + it('opens with Security pre-expanded and the active child highlighted when on its route', () => { + mockPathname = '/home/cedarlingconfig' + render() + expect(screen.getByRole('button', { name: 'menus.security' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + const child = screen.getByRole('button', { + name: 'menus.securityDropdown.cedarlingConfig', + }) + expect(child).toHaveAttribute('aria-current', 'page') + }) + + it('keeps the Notification tile enabled but inert, staying on the modal when tapped', () => { + const onSelect = jest.fn() + const onClose = jest.fn() + render() + const tile = screen.getByRole('button', { name: 'menus.notification' }) + expect(tile).toBeEnabled() + fireEvent.click(tile) + expect(onSelect).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByTestId('mobile-nav-sheet')).toBeInTheDocument() + }) + + it('closes via the close button and the scrim', () => { + const onClose = jest.fn() + render() + const closeButtons = screen.getAllByRole('button', { name: 'actions.close' }) + fireEvent.click(closeButtons[0]) + expect(onClose).toHaveBeenCalled() + }) + + it('closes on Escape', () => { + const onClose = jest.fn() + render() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/admin-ui/app/components/MobileBottomNav/__tests__/sheetConstants.test.ts b/admin-ui/app/components/MobileBottomNav/__tests__/sheetConstants.test.ts new file mode 100644 index 000000000..0c9dd1ce1 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/__tests__/sheetConstants.test.ts @@ -0,0 +1,85 @@ +jest.mock('@/helpers/navigation', () => ({ + ROUTES: { + CUSTOM_SCRIPT_LIST: '/home/scripts', + ATTRIBUTES_LIST: '/attributes', + SERVICES_CACHE: '/config/cache', + SERVICES_PERSISTENCE: '/config/persistence', + SMTP_BASE: '/smtp/smtpmanagement', + SCIM_BASE: '/scim', + FIDO_BASE: '/fido/configuration', + FIDO_METRICS: '/fido/metrics', + JANS_LOCK_BASE: '/jans-lock', + ADMIN_DASHBOARD: '/home/dashboard', + ADMIN_HEALTH: '/home/health', + ADMIN_LICENSE_DETAILS: '/home/license', + ADMIN_MAU_GRAPH: '/home/mau', + ADMIN_SETTINGS: '/home/settings', + ADMIN_MAPPING: '/home/mapping', + ADMIN_CEDARLING_CONFIG: '/home/cedarlingconfig', + WEBHOOK_LIST: '/home/webhooks', + ASSETS_LIST: '/home/assets', + ADMIN_AUDIT_LOGS: '/home/auditlogs', + AUTH_SERVER_CLIENTS_LIST: '/auth-server/clients', + AUTH_SERVER_SCOPES_LIST: '/auth-server/scopes', + AUTH_SERVER_CONFIG_KEYS: '/auth-server/keys', + AUTH_SERVER_CONFIG_PROPERTIES: '/auth-server/properties', + AUTH_SERVER_CONFIG_LOGGING: '/auth-server/logging', + AUTH_SERVER_SSA_LIST: '/auth-server/ssa', + AUTH_SERVER_AUTHN: '/auth-server/authn', + AUTH_SERVER_CONFIG_API: '/auth-server/config-api', + AUTH_SERVER_SESSIONS: '/auth-server/sessions', + USER_MANAGEMENT: '/user/usersmanagement', + }, +})) + +import { isMoreMenuPath, MORE_TILE_DEFS, SHEET } from '../sheetConstants' + +describe('isMoreMenuPath', () => { + it('is true for an exact More-tile route', () => { + expect(isMoreMenuPath('/home/scripts')).toBe(true) + expect(isMoreMenuPath('/scim')).toBe(true) + }) + + it('is true for a nested sub-route of a More-tile route', () => { + expect(isMoreMenuPath('/home/scripts/edit/1')).toBe(true) + }) + + it('is true for a route belonging to a tile child (FIDO/Services)', () => { + expect(isMoreMenuPath('/fido/metrics')).toBe(true) + expect(isMoreMenuPath('/config/persistence')).toBe(true) + }) + + it('is false for a route not owned by any More-tile', () => { + expect(isMoreMenuPath('/auth-server/clients')).toBe(false) + expect(isMoreMenuPath('/home/dashboard')).toBe(false) + }) + + it('does not match a prefix that is not a path boundary', () => { + expect(isMoreMenuPath('/scimmer')).toBe(false) + }) +}) + +describe('MORE_TILE_DEFS', () => { + it('exposes the fixed 8 All Category tiles', () => { + expect(MORE_TILE_DEFS).toHaveLength(8) + }) + + it('leaves the design-only Notification tile without a route or children', () => { + const notification = MORE_TILE_DEFS.find((tile) => tile.key === 'notification') + expect(notification?.path).toBeUndefined() + expect(notification?.children).toBeUndefined() + }) + + it('gives drill-in tiles (FIDO, Services) children with routes', () => { + const fido = MORE_TILE_DEFS.find((tile) => tile.key === 'fido') + expect(fido?.children?.every((child) => !!child.path)).toBe(true) + }) +}) + +describe('SHEET tokens', () => { + it('layers the sheet stack above the app block-ui ceiling', () => { + expect(SHEET.Z_SCRIM).toBeGreaterThan(10000) + expect(SHEET.Z_SHEET).toBeGreaterThan(SHEET.Z_SCRIM) + expect(SHEET.Z_BAR_ELEVATED).toBeGreaterThan(SHEET.Z_SHEET) + }) +}) diff --git a/admin-ui/app/components/MobileBottomNav/__tests__/sheetIcons.test.tsx b/admin-ui/app/components/MobileBottomNav/__tests__/sheetIcons.test.tsx new file mode 100644 index 000000000..dacf7e062 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/__tests__/sheetIcons.test.tsx @@ -0,0 +1,25 @@ +import { isValidElement } from 'react' +import { SHEET_ICON_BY_KEY } from '../sheetIcons' +import { MORE_TILE_DEFS } from '../sheetConstants' + +describe('SHEET_ICON_BY_KEY', () => { + it('maps every icon key to a valid React element', () => { + for (const key of Object.keys(SHEET_ICON_BY_KEY)) { + expect(isValidElement(SHEET_ICON_BY_KEY[key])).toBe(true) + } + }) + + it('provides an icon for every More-tile iconKey', () => { + for (const tile of MORE_TILE_DEFS) { + if (!tile.iconKey) continue + expect(SHEET_ICON_BY_KEY[tile.iconKey]).toBeDefined() + } + }) + + it('tags each icon with the shared sheet-icon className', () => { + for (const key of Object.keys(SHEET_ICON_BY_KEY)) { + const element = SHEET_ICON_BY_KEY[key] as { props: { className?: string } } + expect(element.props.className).toBe('mobile-sheet-icon') + } + }) +}) diff --git a/admin-ui/app/components/MobileBottomNav/constants.ts b/admin-ui/app/components/MobileBottomNav/constants.ts new file mode 100644 index 000000000..2964a00b0 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/constants.ts @@ -0,0 +1,44 @@ +import { ROUTES } from '@/helpers/navigation' + +export const MOBILE_MEDIA_QUERY = '(max-width:767px)' + +export const PRIMARY_TAB_DEFS = [ + { + key: 'home', + titleKey: 'menus.home', + iconKey: 'home', + path: ROUTES.HOME_DASHBOARD, + basePath: ROUTES.PLUGIN_BASE_PATHS.HOME, + }, + { + key: 'auth-server', + titleKey: 'menus.oauthserver', + iconKey: 'oauthserver', + path: ROUTES.AUTH_SERVER_CLIENTS_LIST, + basePath: ROUTES.PLUGIN_BASE_PATHS.AUTH_SERVER, + }, + { + key: 'users', + titleKey: 'menus.users', + iconKey: 'usersmanagement', + path: ROUTES.USER_MANAGEMENT, + basePath: ROUTES.PLUGIN_BASE_PATHS.USER_MANAGEMENT, + directNav: true, + }, +] as const + +export const MORE_TAB_KEY = 'more' + +export const BOTTOM_NAV = { + HEIGHT: 64, + ICON_SIZE: 26, + TAB_GAP: 4, + TAB_MIN_HEIGHT: 44, + ACTIVE_INDICATOR_HEIGHT: 3, + ACTIVE_INDICATOR_WIDTH: 28, + PADDING_X: 27, + Z_ROOT: 1200, + SHADOW_OFFSET_Y: -6, + SHADOW_BLUR: 20, + SHADOW_OPACITY: 0.08, +} as const diff --git a/admin-ui/app/components/MobileBottomNav/sheetConstants.ts b/admin-ui/app/components/MobileBottomNav/sheetConstants.ts new file mode 100644 index 000000000..7f2a54970 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/sheetConstants.ts @@ -0,0 +1,169 @@ +import { ROUTES } from '@/helpers/navigation' + +export const SHEET_KEYS = { + HOME: 'home', + AUTH_SERVER: 'auth-server', + USERS: 'users', + MORE: 'more', +} as const + +export type SheetKey = (typeof SHEET_KEYS)[keyof typeof SHEET_KEYS] + +export type SheetItem = { + key: string + titleKey: string + path?: string + iconKey?: string + children?: readonly SheetItem[] +} + +export const MORE_TILE_DEFS: readonly SheetItem[] = [ + { + key: 'scripts', + titleKey: 'menus.scripts', + iconKey: 'scripts', + path: ROUTES.CUSTOM_SCRIPT_LIST, + }, + { + key: 'user-claims', + titleKey: 'menus.user_claims', + iconKey: 'user_claims', + path: ROUTES.ATTRIBUTES_LIST, + }, + { + key: 'services', + titleKey: 'menus.services', + iconKey: 'services', + children: [ + { key: 'services-cache', titleKey: 'menus.cache', path: ROUTES.SERVICES_CACHE }, + { + key: 'services-persistence', + titleKey: 'menus.persistence', + path: ROUTES.SERVICES_PERSISTENCE, + }, + ], + }, + { key: 'smtp', titleKey: 'menus.smtp', iconKey: 'smtpmanagement', path: ROUTES.SMTP_BASE }, + { key: 'scim', titleKey: 'menus.scim', iconKey: 'scim', path: ROUTES.SCIM_BASE }, + { + key: 'fido', + titleKey: 'menus.fido', + iconKey: 'fidomanagement', + children: [ + { key: 'fido-configuration', titleKey: 'menus.configuration', path: ROUTES.FIDO_BASE }, + { key: 'fido-metrics', titleKey: 'menus.metrics', path: ROUTES.FIDO_METRICS }, + ], + }, + { + key: 'jans-lock', + titleKey: 'menus.jans_lock', + iconKey: 'jans_lock', + path: ROUTES.JANS_LOCK_BASE, + }, + { key: 'notification', titleKey: 'menus.notification', iconKey: 'notification' }, +] as const + +const MORE_MENU_PATHS: readonly string[] = MORE_TILE_DEFS.flatMap((tile) => [ + ...(tile.path ? [tile.path] : []), + ...(tile.children?.map((child) => child.path).filter((p): p is string => !!p) ?? []), +]) + +export const isMoreMenuPath = (pathname: string): boolean => + MORE_MENU_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)) + +export type SectionMenu = { + key: SheetKey + titleKey: string + iconKey: string + items: readonly SheetItem[] +} + +export const SECTION_MENUS: Readonly> = { + 'home': { + key: SHEET_KEYS.HOME, + titleKey: 'menus.home', + iconKey: 'home', + items: [ + { key: 'dashboard', titleKey: 'menus.dashboard', path: ROUTES.ADMIN_DASHBOARD }, + { key: 'health', titleKey: 'menus.health', path: ROUTES.ADMIN_HEALTH }, + { key: 'license', titleKey: 'menus.licenseDetails', path: ROUTES.ADMIN_LICENSE_DETAILS }, + { key: 'mau', titleKey: 'menus.maugraph', path: ROUTES.ADMIN_MAU_GRAPH }, + { key: 'settings', titleKey: 'menus.settings', path: ROUTES.ADMIN_SETTINGS }, + { + key: 'security', + titleKey: 'menus.security', + children: [ + { + key: 'security-mapping', + titleKey: 'menus.securityDropdown.mapping', + path: ROUTES.ADMIN_MAPPING, + }, + { + key: 'security-cedarling', + titleKey: 'menus.securityDropdown.cedarlingConfig', + path: ROUTES.ADMIN_CEDARLING_CONFIG, + }, + ], + }, + { key: 'webhooks', titleKey: 'menus.webhooks', path: ROUTES.WEBHOOK_LIST }, + { key: 'assets', titleKey: 'menus.assets', path: ROUTES.ASSETS_LIST }, + { key: 'audit-logs', titleKey: 'menus.audit_logs', path: ROUTES.ADMIN_AUDIT_LOGS }, + ], + }, + 'auth-server': { + key: SHEET_KEYS.AUTH_SERVER, + titleKey: 'menus.oauthserver', + iconKey: 'oauthserver', + items: [ + { key: 'clients', titleKey: 'menus.clients', path: ROUTES.AUTH_SERVER_CLIENTS_LIST }, + { key: 'scopes', titleKey: 'menus.scopes', path: ROUTES.AUTH_SERVER_SCOPES_LIST }, + { key: 'keys', titleKey: 'menus.keys', path: ROUTES.AUTH_SERVER_CONFIG_KEYS }, + { + key: 'properties', + titleKey: 'menus.properties', + path: ROUTES.AUTH_SERVER_CONFIG_PROPERTIES, + }, + { key: 'logging', titleKey: 'menus.logging', path: ROUTES.AUTH_SERVER_CONFIG_LOGGING }, + { key: 'ssa', titleKey: 'menus.ssa', path: ROUTES.AUTH_SERVER_SSA_LIST }, + { key: 'authentication', titleKey: 'menus.authentication', path: ROUTES.AUTH_SERVER_AUTHN }, + { key: 'config-api', titleKey: 'menus.configuration', path: ROUTES.AUTH_SERVER_CONFIG_API }, + { key: 'sessions', titleKey: 'menus.sessions', path: ROUTES.AUTH_SERVER_SESSIONS }, + ], + }, + 'users': { + key: SHEET_KEYS.USERS, + titleKey: 'menus.users', + iconKey: 'usersmanagement', + items: [{ key: 'users-list', titleKey: 'menus.users', path: ROUTES.USER_MANAGEMENT }], + }, +} + +export const SHEET = { + TILE_CHIP_WIDTH: 40, + TILE_CHIP_HEIGHT: 41, + TILE_CHIP_RADIUS: 7, + TILE_ICON_SIZE: 24, + TILE_GAP: 8, + GRID_COLUMNS: 4, + GRID_ROW_GAP: 20, + LIST_ROW_GAP: 20, + LIST_ITEM_GAP: 12, + LIST_PADDING_X: 32, + LIST_PADDING_Y: 20, + SUBLIST_INDENT: 16, + GRID_PADDING: '20px 16px 8px', + HEADER_GAP: 8, + HEADER_PADDING: '20px 24px 16px', + HEADER_ICON_SIZE: 24, + CLOSE_SIZE: 22, + BACK_SIZE: 34, + BACK_ICON_SIZE: 20, + MAX_HEIGHT: '80vh', + SHADOW_OFFSET_Y: -4, + SHADOW_BLUR: 25, + SHADOW_OPACITY: 0.1, + SCRIM_OPACITY: 0.6, + Z_SCRIM: 10001, + Z_SHEET: 10002, + Z_BAR_ELEVATED: 10003, +} as const diff --git a/admin-ui/app/components/MobileBottomNav/sheetIcons.tsx b/admin-ui/app/components/MobileBottomNav/sheetIcons.tsx new file mode 100644 index 000000000..bbdca1fd3 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/sheetIcons.tsx @@ -0,0 +1,28 @@ +import type { JSX } from 'react' +import NotificationsNoneOutlinedIcon from '@mui/icons-material/NotificationsNoneOutlined' +import { + HomeIcon, + OAuthIcon, + UsersIcon, + ScriptsIcon, + UserClaimsIcon, + ServicesIcon, + SmtpZoneIcon, + ScimIcon, + FidoIcon, + LockIcon, +} from '../SVG' + +export const SHEET_ICON_BY_KEY: Record = { + home: , + oauthserver: , + usersmanagement: , + scripts: , + user_claims: , + services: , + smtpmanagement: , + scim: , + fidomanagement: , + jans_lock: , + notification: , +} diff --git a/admin-ui/app/components/MobileBottomNav/types.ts b/admin-ui/app/components/MobileBottomNav/types.ts new file mode 100644 index 000000000..c22c17fc8 --- /dev/null +++ b/admin-ui/app/components/MobileBottomNav/types.ts @@ -0,0 +1,30 @@ +import type React from 'react' + +export type MobileNavTab = { + key: string + titleKey: string + path?: string + basePath?: string + directNav?: boolean + isMore?: boolean + icon: React.ReactNode +} + +export type MobileBottomNavThemeColors = { + background: string + shadow: string + border: string + active: string + inactive: string +} + +export type MobileNavSheetThemeColors = { + background: string + border: string + text: string + listText: string + title: string + chip: string + active: string + white: string +} diff --git a/admin-ui/app/constants/ui.ts b/admin-ui/app/constants/ui.ts index 24b39bfdf..66052bcdd 100644 --- a/admin-ui/app/constants/ui.ts +++ b/admin-ui/app/constants/ui.ts @@ -87,6 +87,7 @@ export const SPACING = { export const BORDER_RADIUS = { DEFAULT: 16, LARGE: 24, + MOBILE_SHEET: 20, MEDIUM: 14, ACCORDION: 10, SMALL_MEDIUM: 8, diff --git a/admin-ui/app/customColors.ts b/admin-ui/app/customColors.ts index ac72bb61e..73dd818b1 100644 --- a/admin-ui/app/customColors.ts +++ b/admin-ui/app/customColors.ts @@ -100,6 +100,15 @@ const customColors = { cedarInfoBorderLight: '#a6d3e6', cedarInfoTextLight: '#4f8196', + mobileNavActive: '#00a65d', + mobileNavInactiveLight: '#6b7b8e', + mobileNavInactiveDark: '#8ca1b4', + + mobileSheetTileChipLight: '#f5f6f8', + mobileSheetTileChipDark: '#16395d', + + mobileSheetTitleDark: '#e1e5ea', + // Other darkBorderGradientBase: '#00d5e6', ribbonShadowColor: '#1a237e', diff --git a/admin-ui/app/index.tsx b/admin-ui/app/index.tsx index b9d965d1f..6552bd22e 100644 --- a/admin-ui/app/index.tsx +++ b/admin-ui/app/index.tsx @@ -1,4 +1,3 @@ -import { lazy, Suspense } from 'react' import { createRoot } from 'react-dom/client' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' @@ -13,15 +12,6 @@ import GluuLoader from '@/routes/Apps/Gluu/GluuLoader' import './styles/index.css' import 'bootstrap/dist/css/bootstrap.css' -// react-query devtools — dev only; the dynamic import is statically dropped from the production bundle. -const ReactQueryDevtools = import.meta.env.PROD - ? () => null - : lazy(() => - import('@tanstack/react-query-devtools').then((mod) => ({ - default: mod.ReactQueryDevtools, - })), - ) - if (typeof document !== 'undefined' && typeof document.startViewTransition === 'function') { const originalStartViewTransition = document.startViewTransition.bind(document) document.startViewTransition = (callback) => { @@ -55,9 +45,6 @@ root.render( - - - , diff --git a/admin-ui/app/layout/__tests__/default.test.tsx b/admin-ui/app/layout/__tests__/default.test.tsx index 71fc274b5..080afd069 100644 --- a/admin-ui/app/layout/__tests__/default.test.tsx +++ b/admin-ui/app/layout/__tests__/default.test.tsx @@ -28,6 +28,13 @@ jest.mock('../../routes', () => ({ RoutedSidebars: () =>
, })) +// MobileBottomNav has its own suite and pulls in theme/router at import time; +// stub it so this test stays focused on AppLayout's theme resolution. +jest.mock('@/components/MobileBottomNav/MobileBottomNav', () => ({ + __esModule: true, + default: () =>
, +})) + // SCSS + favicon asset imports are handled by jest asset mocks, but stub the // stylesheet side-effects and the .ico favicon (not covered by the asset mapper) // explicitly to keep the module graph small. diff --git a/admin-ui/app/layout/default.tsx b/admin-ui/app/layout/default.tsx index 039d17850..167381c3e 100755 --- a/admin-ui/app/layout/default.tsx +++ b/admin-ui/app/layout/default.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react' import { Layout, ThemeProvider } from 'Components' +import MobileBottomNav from '@/components/MobileBottomNav/MobileBottomNav' import { DEFAULT_THEME, isValidTheme } from '@/context/theme/constants' import { logger } from '@/utils/logger' import { storage } from '@/utils/storage' @@ -84,6 +85,10 @@ const AppLayout: React.FC = ({ children }) => { {children} + {/* Not a Navbar/Sidebar/Content slot — Layout renders it via otherChildren + at the root, where it self-positions fixed at the viewport bottom on + mobile only. */} + ) diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 235e4475d..ac0cbbe53 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -858,7 +858,10 @@ "static_configuration": "Static Configuration", "dynamic_configuration": "Dynamic Configuration", "upload_agama_project": "Upload Agama project", - "add_community_project": "Add a Community Project" + "add_community_project": "Add a Community Project", + "more": "More", + "allCategory": "All Category", + "notification": "Notification" }, "footer": { "copyright": "(C) {{year}} All Rights Reserved.", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index c50403330..2ca879f25 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -858,7 +858,10 @@ "dynamic_configuration": "Configuración Dinámica", "upload_agama_project": "Subir Proyecto Agama", "add_community_project": "Agregar un Proyecto de la Comunidad", - "metrics": "Métricas" + "metrics": "Métricas", + "more": "Más", + "allCategory": "Todas las categorías", + "notification": "Notificación" }, "footer": { "copyright": "(C) {{year}} Todos los derechos reservados.", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 89abe471f..327066a8a 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -155,7 +155,10 @@ "upload_agama_project": "Télécharger le projet Agama", "add_community_project": "Ajouter un projet communautaire", "agama": "Agama", - "smtp": "SMTP" + "smtp": "SMTP", + "more": "Plus", + "allCategory": "Toutes les catégories", + "notification": "Notification" }, "actions": { "accept": "J'accepte", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index f6e7346e1..6ced63545 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -154,7 +154,10 @@ "jans_link": "Link Jans", "metrics": "Métricas", "smtp": "SMTP", - "webhooks": "Webhooks" + "webhooks": "Webhooks", + "more": "Mais", + "allCategory": "Todas as categorias", + "notification": "Notificação" }, "actions": { "accept": "Aceitar", diff --git a/admin-ui/app/styles/miltonbo/scss/_layout.scss b/admin-ui/app/styles/miltonbo/scss/_layout.scss index 29e5b9d3c..922e54256 100644 --- a/admin-ui/app/styles/miltonbo/scss/_layout.scss +++ b/admin-ui/app/styles/miltonbo/scss/_layout.scss @@ -60,6 +60,13 @@ @media (max-width: breakpoint-max("md", $grid-breakpoints)) { width: 0; } + + // On mobile the desktop sidebar is fully replaced by the bottom + // navigation bar, so hide it outright rather than merely collapsing + // its width. Breakpoint mirrors MOBILE_MEDIA_QUERY (max-width: 767px). + @media (max-width: 767px) { + display: none; + } } &__content { @@ -68,6 +75,12 @@ @media (max-width: breakpoint-max("md", $grid-breakpoints)) { padding: 0.5rem; } + + // Leave room for the fixed mobile bottom navigation bar so page + // content isn't hidden behind it (bar height + safe-area inset). + @media (max-width: 767px) { + padding-bottom: calc(64px + env(safe-area-inset-bottom) + 0.5rem); + } } &__wrap { From 37e54e26ee86ccdf0d3b258324bab2ce0e670d1e Mon Sep 17 00:00:00 2001 From: faisalsiddique4400 Date: Tue, 7 Jul 2026 19:05:15 +0500 Subject: [PATCH 2/3] feat(admin-ui): resolve code rabbit issues Signed-off-by: faisalsiddique4400 --- .../app/components/Layout/LayoutContent.tsx | 3 +- .../MobileBottomNav/MobileBottomNav.tsx | 12 ++------ .../MobileBottomNav/MobileNavSheet.style.ts | 2 +- .../MobileBottomNav/MobileNavSheet.tsx | 14 ++++++++-- .../__tests__/MobileNavSheet.test.tsx | 10 ++++++- .../__tests__/sheetIcons.test.tsx | 4 +-- .../components/MobileBottomNav/constants.ts | 3 +- .../MobileBottomNav/sheetConstants.ts | 6 +++- .../components/MobileBottomNav/sheetIcons.tsx | 28 +++++++++++-------- admin-ui/app/constants/ui.ts | 2 ++ admin-ui/app/locales/en/translation.json | 3 +- admin-ui/app/locales/es/translation.json | 3 +- admin-ui/app/locales/fr/translation.json | 3 +- admin-ui/app/locales/pt/translation.json | 3 +- .../app/styles/miltonbo/scss/_layout.scss | 6 ++-- 15 files changed, 66 insertions(+), 36 deletions(-) diff --git a/admin-ui/app/components/Layout/LayoutContent.tsx b/admin-ui/app/components/Layout/LayoutContent.tsx index 2e3e0bac8..fee41cac6 100755 --- a/admin-ui/app/components/Layout/LayoutContent.tsx +++ b/admin-ui/app/components/Layout/LayoutContent.tsx @@ -2,7 +2,7 @@ import React, { use, useEffect, useMemo, useRef } from 'react' import { ThemeContext } from '@/context/theme/themeContext' import getThemeColor, { themeConfig } from '@/context/theme/config' import customColors, { getCustomColorsAsCssVars, getLoadingOverlayRgba } from '@/customColors' -import { getListHoverOpacity, SCROLLBAR } from '@/constants' +import { getListHoverOpacity, SCROLLBAR, MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants' import { THEME_LIGHT, THEME_DARK } from '@/context/theme/constants' import type { LayoutContentProps } from './types' @@ -42,6 +42,7 @@ const LayoutContent: React.FC & { layoutPartName: string } = '--theme-scrollbar-width': `${SCROLLBAR.WIDTH}px`, '--theme-scrollbar-height': `${SCROLLBAR.HEIGHT}px`, '--theme-scrollbar-radius': `${SCROLLBAR.BORDER_RADIUS}px`, + '--mobile-bottom-nav-height': `${MOBILE_BOTTOM_NAV_HEIGHT}px`, '--theme-sidebar-background': themeColors.menu.background, '--theme-navbar-background': themeColors.navbar.background, '--theme-navbar-border': themeColors.navbar.border, diff --git a/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx b/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx index 5901a8778..796c6dff6 100644 --- a/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx +++ b/admin-ui/app/components/MobileBottomNav/MobileBottomNav.tsx @@ -8,19 +8,13 @@ import customColors from '@/customColors' import { useTheme } from '@/context/theme/themeContext' import getThemeColor from '@/context/theme/config' import { THEME_LIGHT } from '@/context/theme/constants' -import { HomeIcon, OAuthIcon, UsersIcon } from '../SVG' import { useStyles } from './MobileBottomNav.style' import { MOBILE_MEDIA_QUERY, PRIMARY_TAB_DEFS, MORE_TAB_KEY } from './constants' import MobileNavSheet from './MobileNavSheet' +import { PRIMARY_ICON_BY_KEY } from './sheetIcons' import { isMoreMenuPath, type SheetItem, type SheetKey } from './sheetConstants' import type { MobileNavTab, MobileBottomNavThemeColors } from './types' -const ICON_BY_KEY: Record = { - home: , - oauthserver: , - usersmanagement: , -} - const MobileBottomNav = (): JSX.Element | null => { const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) const { t } = useTranslation() @@ -50,7 +44,7 @@ const MobileBottomNav = (): JSX.Element | null => { path: def.path, basePath: def.basePath, directNav: 'directNav' in def ? def.directNav : undefined, - icon: ICON_BY_KEY[def.iconKey], + icon: PRIMARY_ICON_BY_KEY[def.iconKey], })) return [ ...primary, @@ -100,7 +94,7 @@ const MobileBottomNav = (): JSX.Element | null => { <>