Merge pull request #18996 from overleaf/td-bs5-nav-react

Main navigation React component

GitOrigin-RevId: c99a4b4a2f6fd02618689f829681118b2b64aa8d
This commit is contained in:
Tim Down
2024-08-22 08:05:03 +00:00
committed by Copybot
parent 2296287e61
commit d5643d53b3
33 changed files with 669 additions and 15 deletions
@@ -0,0 +1,9 @@
import ReactDOM from 'react-dom'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import getMeta from '@/utils/meta'
const element = document.getElementById('navbar-container')
if (element) {
const navbarProps = getMeta('ol-navbar')
ReactDOM.render(<DefaultNavbar {...navbarProps} />, element)
}
@@ -99,8 +99,8 @@ export const DropdownMenu = forwardRef<
})
DropdownMenu.displayName = 'DropdownMenu'
export function DropdownDivider({ as = 'li' }: DropdownDividerProps) {
return <BS5DropdownDivider as={as} />
export function DropdownDivider({ as = 'li', ...props }: DropdownDividerProps) {
return <BS5DropdownDivider as={as} {...props} />
}
export function DropdownHeader({ as = 'li', ...props }: DropdownHeaderProps) {
@@ -0,0 +1,49 @@
import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
export default function AdminMenu({
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
adminUrl,
}: Pick<
DefaultNavbarMetadata,
| 'canDisplayAdminMenu'
| 'canDisplayAdminRedirect'
| 'canDisplaySplitTestMenu'
| 'canDisplaySurveyMenu'
| 'adminUrl'
>) {
return (
<NavDropdownMenu title="Admin" className="subdued">
{canDisplayAdminMenu ? (
<>
<NavDropdownLinkItem href="/admin">Manage Site</NavDropdownLinkItem>
<NavDropdownLinkItem href="/admin/user">
Manage Users
</NavDropdownLinkItem>
<NavDropdownLinkItem href="/admin/project">
Project URL lookup
</NavDropdownLinkItem>
</>
) : null}
{canDisplayAdminRedirect && adminUrl ? (
<NavDropdownLinkItem href={adminUrl}>
Switch to Admin
</NavDropdownLinkItem>
) : null}
{canDisplaySplitTestMenu ? (
<NavDropdownLinkItem href="/admin/split-test">
Manage Feature Flags
</NavDropdownLinkItem>
) : null}
{canDisplaySurveyMenu ? (
<NavDropdownLinkItem href="/admin/survey">
Manage Surveys
</NavDropdownLinkItem>
) : null}
</NavDropdownMenu>
)
}
@@ -0,0 +1,25 @@
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
import { sendMB } from '@/infrastructure/event-tracking'
import { useTranslation } from 'react-i18next'
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
export default function ContactUsItem() {
const { t } = useTranslation()
const { modal, showModal } = useContactUsModal({ autofillProjectUrl: false })
return (
<>
<NavDropdownLinkItem
href="#"
onClick={() => {
sendMB('menu-clicked-contact')
showModal()
}}
>
{t('contact_us')}
</NavDropdownLinkItem>
<UserProvider>{modal}</UserProvider>
</>
)
}
@@ -0,0 +1,119 @@
import { sendMB } from '@/infrastructure/event-tracking'
import { useTranslation } from 'react-i18next'
import { Button, Container, Nav, Navbar } from 'react-bootstrap-5'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import AdminMenu from '@/features/ui/components/bootstrap-5/navbar/admin-menu'
import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
import NavItemFromData from '@/features/ui/components/bootstrap-5/navbar/nav-item-from-data'
import LoggedInItems from '@/features/ui/components/bootstrap-5/navbar/logged-in-items'
import HeaderLogoOrTitle from '@/features/ui/components/bootstrap-5/navbar/header-logo-or-title'
import MaterialIcon from '@/shared/components/material-icon'
function LoggedOutItems() {
return <span>Logged out</span>
}
function DefaultNavbar(props: DefaultNavbarMetadata) {
const {
customLogo,
title,
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
enableUpgradeButton,
suppressNavbarRight,
suppressNavContentLinks,
showSubscriptionLink,
sessionUser,
adminUrl,
items,
} = props
const { t } = useTranslation()
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<>
<Navbar className="navbar-default navbar-main" expand="lg">
<Container className="navbar-container" fluid>
<div className="navbar-header">
<HeaderLogoOrTitle title={title} customLogo={customLogo} />
{enableUpgradeButton ? (
<Button
as="a"
href="/user/subscription/plans"
className="me-2 d-md-none"
onClick={() => {
sendMB('upgrade-button-click', {
source: 'dashboard-top',
'project-dashboard-react': 'enabled',
'is-dashboard-sidebar-hidden': 'true',
'is-screen-width-less-than-768px': 'true',
})
}}
>
{t('upgrade')}
</Button>
) : null}
</div>
{suppressNavbarRight ? null : (
<>
<Navbar.Toggle
aria-controls="navbar-main-collapse"
aria-expanded="false"
aria-label={t('main_navigation')}
>
<MaterialIcon type="menu" />
</Navbar.Toggle>
<Navbar.Collapse
id="navbar-main-collapse"
className="justify-content-end"
>
<Nav as="ul" className="ms-auto" role="menubar">
{canDisplayAdminMenu ||
canDisplayAdminRedirect ||
canDisplaySplitTestMenu ? (
<AdminMenu
canDisplayAdminMenu={canDisplayAdminMenu}
canDisplayAdminRedirect={canDisplayAdminRedirect}
canDisplaySplitTestMenu={canDisplaySplitTestMenu}
canDisplaySurveyMenu={canDisplaySurveyMenu}
adminUrl={adminUrl}
/>
) : null}
{items.map((item, index) => {
const showNavItem =
(item.only_when_logged_in && sessionUser) ||
(item.only_when_logged_out && sessionUser) ||
(!item.only_when_logged_out &&
!item.only_when_logged_in &&
!item.only_content_pages) ||
(item.only_content_pages && !suppressNavContentLinks)
return showNavItem ? (
<NavItemFromData item={item} key={index} />
) : null
})}
{sessionUser ? (
<LoggedInItems
sessionUser={sessionUser}
showSubscriptionLink={showSubscriptionLink}
/>
) : (
<LoggedOutItems />
)}
</Nav>
</Navbar.Collapse>
</>
)}
</Container>
</Navbar>
</>
)
}
export default DefaultNavbar
@@ -0,0 +1,32 @@
import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
import getMeta from '@/utils/meta'
export default function HeaderLogoOrTitle({
customLogo,
title,
}: Pick<DefaultNavbarMetadata, 'customLogo' | 'title'>) {
const { appName } = getMeta('ol-ExposedSettings')
if (customLogo) {
return (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href="/"
aria-label={appName}
className="navbar-brand"
style={{ backgroundImage: `url("${customLogo}")` }}
/>
)
} else if (title) {
return (
<a href="/" aria-label={appName} className="navbar-title">
{title}
</a>
)
} else {
return (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a href="/" aria-label={appName} className="navbar-brand" />
)
}
}
@@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next'
import { Dropdown } from 'react-bootstrap-5'
import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
import getMeta from '@/utils/meta'
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
import type { NavbarSessionUser } from '@/features/ui/components/types/navbar'
import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
export default function LoggedInItems({
sessionUser,
showSubscriptionLink,
}: {
sessionUser: NavbarSessionUser
showSubscriptionLink: boolean
}) {
const { t } = useTranslation()
const logOutFormId = 'logOutForm'
return (
<>
<NavLinkItem href="/project">{t('projects')}</NavLinkItem>
<NavDropdownMenu title={t('Account')}>
<Dropdown.Item as="li" disabled role="menuitem">
{sessionUser.email}
</Dropdown.Item>
<NavDropdownDivider />
<NavDropdownLinkItem href="/user/settings">
{t('Account Settings')}
</NavDropdownLinkItem>
{showSubscriptionLink ? (
<NavDropdownLinkItem href="/user/subscription">
{t('subscription')}
</NavDropdownLinkItem>
) : null}
<NavDropdownDivider />
<NavDropdownItem>
{
// The button is outside the form but still belongs to it via the
// form attribute. The reason to do this is that if the button is
// inside the form, screen readers will not count it in the total
// number of menu items
}
<Dropdown.Item
as="button"
type="submit"
form={logOutFormId}
role="menuitem"
>
{t('log_out')}
</Dropdown.Item>
<form id={logOutFormId} method="POST" action="/logout">
<input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
</form>
</NavDropdownItem>
</NavDropdownMenu>
</>
)
}
@@ -0,0 +1,5 @@
import { DropdownDivider } from '@/features/ui/components/bootstrap-5/dropdown-menu'
export default function NavDropdownDivider() {
return <DropdownDivider className="d-none d-lg-block" />
}
@@ -0,0 +1,44 @@
import type { NavbarDropdownItemData } from '@/features/ui/components/types/navbar'
import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
import { sendMB } from '@/infrastructure/event-tracking'
import { isDropdownLinkItem } from '@/features/ui/components/bootstrap-5/navbar/util'
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
import ContactUsItem from '@/features/ui/components/bootstrap-5/navbar/contact-us-item'
export default function NavDropdownFromData({
item,
}: {
item: NavbarDropdownItemData
}) {
return (
<NavDropdownMenu title={item.translatedText} className={item.class}>
{item.dropdown.map((child, index) => {
if ('divider' in child) {
return <NavDropdownDivider key={index} />
} else if ('isContactUs' in child) {
return <ContactUsItem key={index} />
} else if (isDropdownLinkItem(child)) {
return (
<NavDropdownLinkItem
key={index}
href={child.url}
onClick={() => {
sendMB(child.event)
}}
>
{child.translatedText}
</NavDropdownLinkItem>
)
} else {
return (
<NavDropdownItem key={index}>
{child.translatedText}
</NavDropdownItem>
)
}
})}
</NavDropdownMenu>
)
}
@@ -0,0 +1,5 @@
import { ReactNode } from 'react'
export default function NavDropdownItem({ children }: { children: ReactNode }) {
return <li role="none">{children}</li>
}
@@ -0,0 +1,22 @@
import { ReactNode } from 'react'
import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
import { DropdownItem } from 'react-bootstrap-5'
import { DropdownItemProps } from 'react-bootstrap-5/DropdownItem'
export default function NavDropdownLinkItem({
href,
onClick,
children,
}: {
href: string
onClick?: DropdownItemProps['onClick']
children: ReactNode
}) {
return (
<NavDropdownItem>
<DropdownItem href={href} role="menuitem" onClick={onClick}>
{children}
</DropdownItem>
</NavDropdownItem>
)
}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react'
import { Dropdown } from 'react-bootstrap-5'
export default function NavDropdownMenu({
title,
className,
children,
}: {
title: string
className?: string
children: ReactNode
}) {
// Can't use a NavDropdown here because it's impossible to render the menu as
// a <ul> element using NavDropdown
return (
<Dropdown as="li" role="none" className={className}>
<Dropdown.Toggle role="menuitem">{title}</Dropdown.Toggle>
<Dropdown.Menu as="ul" role="menu" align="end">
{children}
</Dropdown.Menu>
</Dropdown>
)
}
@@ -0,0 +1,29 @@
import type { NavbarItemData } from '@/features/ui/components/types/navbar'
import {
isDropdownItem,
isLinkItem,
} from '@/features/ui/components/bootstrap-5/navbar/util'
import NavDropdownFromData from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-from-data'
import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item'
import { sendMB } from '@/infrastructure/event-tracking'
import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
export default function NavItemFromData({ item }: { item: NavbarItemData }) {
if (isDropdownItem(item)) {
return <NavDropdownFromData item={item} />
} else if (isLinkItem(item)) {
return (
<NavLinkItem
className={item.class}
href={item.url}
onClick={() => {
sendMB(item.event)
}}
>
{item.translatedText}
</NavLinkItem>
)
} else {
return <NavItem className={item.class}>{item.translatedText}</NavItem>
}
}
@@ -0,0 +1,10 @@
import { Nav, NavItemProps } from 'react-bootstrap-5'
export default function NavItem(props: Omit<NavItemProps, 'as'>) {
const { children, ...rest } = props
return (
<Nav.Item as="li" role="none" {...rest}>
{children}
</Nav.Item>
)
}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react'
import { Nav } from 'react-bootstrap-5'
import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item'
export default function NavLinkItem({
href,
className,
onClick,
children,
}: {
href: string
className?: string
onClick?: React.ComponentProps<typeof Nav.Link>['onClick']
children: ReactNode
}) {
return (
<NavItem className={className}>
<Nav.Link role="menuitem" href={href} onClick={onClick}>
{children}
</Nav.Link>
</NavItem>
)
}
@@ -0,0 +1,23 @@
import type {
NavbarDropdownItem,
NavbarDropdownItemData,
NavbarDropdownLinkItem,
NavbarItemData,
NavbarLinkItemData,
} from '@/features/ui/components/types/navbar'
export function isDropdownLinkItem(
item: NavbarDropdownItem
): item is NavbarDropdownLinkItem {
return 'url' in item
}
export function isDropdownItem(
item: NavbarItemData
): item is NavbarDropdownItemData {
return 'dropdown' in item
}
export function isLinkItem(item: NavbarItemData): item is NavbarLinkItemData {
return 'url' in item
}
@@ -0,0 +1,20 @@
import type {
NavbarItemData,
NavbarSessionUser,
} from '@/features/ui/components/types/navbar'
export type DefaultNavbarMetadata = {
customLogo?: string
title?: string
canDisplayAdminMenu: boolean
canDisplayAdminRedirect: boolean
canDisplaySplitTestMenu: boolean
canDisplaySurveyMenu: boolean
enableUpgradeButton: boolean
suppressNavbarRight: boolean
suppressNavContentLinks: boolean
showSubscriptionLink: boolean
sessionUser?: NavbarSessionUser
adminUrl?: string
items: NavbarItemData[]
}
@@ -55,6 +55,7 @@ export type DropdownMenuProps = PropsWithChildren<{
export type DropdownDividerProps = PropsWithChildren<{
as?: ElementType
className?: string
}>
export type DropdownHeaderProps = PropsWithChildren<{
@@ -0,0 +1,52 @@
export interface NavbarDropdownDivider {
divider: true
}
export interface NavbarDropdownContactUsItem {
isContactUs: true
}
export interface NavbarDropdownTextItem {
text: string
translatedText: string
class?: string
}
export interface NavbarDropdownLinkItem extends NavbarDropdownTextItem {
url: string
event: string
eventSegmentation?: Record<string, any>
}
export type NavbarDropdownItem =
| NavbarDropdownDivider
| NavbarDropdownContactUsItem
| NavbarDropdownTextItem
| NavbarDropdownLinkItem
export type NavbarItemDropdownData = NavbarDropdownItem[]
export interface NavbarTextItemData {
text: string
translatedText: string
only_when_logged_in?: boolean
only_when_logged_out?: boolean
only_content_pages?: boolean
class?: string
}
export interface NavbarDropdownItemData extends NavbarTextItemData {
dropdown: NavbarItemDropdownData
}
export interface NavbarLinkItemData extends NavbarTextItemData {
url: string
event: string
}
export type NavbarItemData =
| NavbarDropdownItemData
| NavbarLinkItemData
| NavbarTextItemData
export type NavbarSessionUser = { email: string }
@@ -1,7 +1,7 @@
import getMeta from '@/utils/meta'
// The reason this is a function to ensure that meta tag is read before any
// isBootstrap5 check is performed
// The reason this is a function is to ensure that the meta tag is read before
// any isBootstrap5 check is performed
export const isBootstrap5 = () => getMeta('ol-bootstrapVersion') === 5
export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {