1
0
mirror of https://github.com/containous/traefik.git synced 2025-11-22 00:23:55 +03:00

Compare commits

...

2 Commits

Author SHA1 Message Date
Gina A.
8bdcd72042 Web UI dashboard improvements 2025-11-21 09:00:05 +01:00
kevinpollet
2ad42cd0ec Merge branch v3.6 into master 2025-11-07 16:47:21 +01:00
14 changed files with 1861 additions and 323 deletions

View File

@@ -49,7 +49,7 @@
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@traefiklabs/faency": "11.1.4",
"@traefiklabs/faency": "12.0.4",
"@types/lodash": "^4.17.16",
"@types/node": "^22.15.18",
"@types/react": "^18.2.0",

View File

@@ -1,6 +1,7 @@
import { AriaTr, VariantProps, styled } from '@traefiklabs/faency'
import { ComponentProps, forwardRef, ReactNode } from 'react'
import { useHref } from 'react-router-dom'
import { useHrefWithReturnTo } from 'hooks/use-href-with-return-to'
const UnstyledLink = styled('a', {
color: 'inherit',
@@ -18,7 +19,7 @@ type ClickableRowProps = ComponentProps<typeof AriaTr> &
}
export default forwardRef<HTMLTableRowElement | null, ClickableRowProps>(({ children, css, to, ...props }, ref) => {
const href = useHref(to)
const href = useHrefWithReturnTo(to)
return (
<AriaTr asChild interactive ref={ref} css={css} {...props}>

View File

@@ -1,6 +1,4 @@
import { Box, Button, Flex, TextField } from '@traefiklabs/faency'
// eslint-disable-next-line import/no-unresolved
import { InputHandle } from '@traefiklabs/faency/dist/components/Input'
import { Box, Button, Flex, TextField, InputHandle } from '@traefiklabs/faency'
import { isUndefined, omitBy } from 'lodash'
import { useCallback, useRef, useState } from 'react'
import { FiSearch, FiXCircle } from 'react-icons/fi'

View File

@@ -7,6 +7,7 @@ import { StatusWrapper } from './ResourceStatus'
import { colorByStatus } from './Status'
import Tooltip from 'components/Tooltip'
import { useGetUrlWithReturnTo } from 'hooks/use-href-with-return-to'
const CustomHeading = styled(H2, {
display: 'flex',
@@ -125,9 +126,25 @@ const CardSkeleton = ({ bigDescription }: { bigDescription?: boolean }) => {
)
}
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
const CardItem = ({ card }) => {
const navigate = useNavigate()
const href = useGetUrlWithReturnTo(card.link)
return (
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
<FlexLink
data-testid={card.link}
onClick={(): false | void => !!card.link && navigate(href)}
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
>
<ItemTitle>{card.title}</ItemTitle>
<CardDescription>{card.description}</CardDescription>
</FlexLink>
</SpacedCard>
)
}
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
return (
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
<SectionHeader icon={icon} title={title} />
@@ -135,20 +152,7 @@ export const CardListSection = ({ icon, title, cards, isLast, bigDescription }:
<CardListColumn>
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
{!cards && <CardSkeleton bigDescription={bigDescription} />}
{cards
?.filter((c) => !!c.description)
.map((card) => (
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
<FlexLink
data-testid={card.link}
onClick={(): false | void => !!card.link && navigate(card.link)}
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
>
<ItemTitle>{card.title}</ItemTitle>
<CardDescription>{card.description}</CardDescription>
</FlexLink>
</SpacedCard>
))}
{cards?.filter((c) => !!c.description).map((card, idx) => <CardItem key={`card-${idx}`} card={card} />)}
<Box css={{ height: '16px' }}>&nbsp;</Box>
</Flex>
</CardListColumn>

View File

@@ -0,0 +1,238 @@
import { renderHook } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { useGetUrlWithReturnTo, useHrefWithReturnTo, useRouterReturnTo } from './use-href-with-return-to'
describe('useGetUrlWithReturnTo', () => {
const createWrapper = (initialPath = '/') => {
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
}
it('should append current path as returnTo query param', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target'), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent%2Fpath')
})
it('should append current path with search params as returnTo', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target'), {
wrapper: createWrapper('/current/path?foo=bar'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent%2Fpath%3Ffoo%3Dbar')
})
it('should use initialReturnTo when provided', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target', '/custom/return'), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('/target?returnTo=%2Fcustom%2Freturn')
})
it('should return the href as-is when href is empty string', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo(''), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('')
})
it('should handle href with existing query params', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target?existing=param'), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('/target?existing=param&returnTo=%2Fcurrent%2Fpath')
})
})
describe('useHrefWithReturnTo', () => {
const createWrapper = (initialPath = '/') => {
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
}
it('should return resolved href with returnTo param containing current path', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
wrapper: createWrapper('/current'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent')
})
it('should include current search params in returnTo', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
wrapper: createWrapper('/current?foo=bar&baz=qux'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent%3Ffoo%3Dbar%26baz%3Dqux')
})
it('should use custom returnTo when provided instead of current path', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target', '/custom/return'), {
wrapper: createWrapper('/current'),
})
expect(result.current).toBe('/target?returnTo=%2Fcustom%2Freturn')
})
it('should handle absolute paths correctly', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/http/routers'), {
wrapper: createWrapper('/tcp/services'),
})
expect(result.current).toBe('/http/routers?returnTo=%2Ftcp%2Fservices')
})
it('should preserve existing query params in target href', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target?existing=param'), {
wrapper: createWrapper('/current'),
})
expect(result.current).toBe('/target?existing=param&returnTo=%2Fcurrent')
})
it('should return root path when href is empty', () => {
const { result } = renderHook(() => useHrefWithReturnTo(''), {
wrapper: createWrapper('/current'),
})
// useHref converts empty string to root path
expect(result.current).toBe('/')
})
it('should handle complex nested paths in returnTo', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
wrapper: createWrapper('/http/routers/my-router-123'),
})
expect(result.current).toBe('/target?returnTo=%2Fhttp%2Frouters%2Fmy-router-123')
})
})
describe('useRouterReturnTo', () => {
const createWrapper = (initialPath = '/') => {
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
}
it('should return null when no returnTo query param exists', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current'),
})
expect(result.current.returnTo).toBeNull()
expect(result.current.returnToLabel).toBeNull()
})
it('should extract returnTo from query params', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers'),
})
expect(result.current.returnTo).toBe('/http/routers')
})
it('should generate correct label for HTTP routers (plural)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers'),
})
expect(result.current.returnToLabel).toBe('HTTP routers')
})
it('should generate correct label for HTTP router (singular)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers/router-1'),
})
expect(result.current.returnToLabel).toBe('HTTP router')
})
it('should generate fallback label for unknown routes (plural)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/custom/resources'),
})
expect(result.current.returnToLabel).toBe('Custom resources')
})
it('should handle malformed returnTo paths gracefully', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/'),
})
expect(result.current.returnTo).toBe('/')
expect(result.current.returnToLabel).toBe('Back')
})
it('should handle returnTo with query params', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test'),
})
expect(result.current.returnTo).toContain('/http/routers')
expect(result.current.returnToLabel).toBe('HTTP routers')
})
it('should strip query params from path when generating label', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test&status=active'),
})
expect(result.current.returnToLabel).toBe('HTTP routers')
expect(result.current.returnToLabel).not.toContain('filter')
expect(result.current.returnToLabel).not.toContain('status')
})
it('should strip query params from subpath when generating label', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/tcp/services?page=2'),
})
expect(result.current.returnToLabel).toBe('TCP services')
})
it('should handle query params with multiple question marks gracefully', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test?extra=param'),
})
// Should handle edge case with multiple question marks (invalid URL but should not crash)
expect(result.current.returnToLabel).toBe('HTTP routers')
})
it('should handle path with query params but no subpath', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http?foo=bar'),
})
expect(result.current.returnToLabel).toBe('Http')
})
it('should handle empty query string (path ending with ?)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/tcp/middlewares?'),
})
expect(result.current.returnToLabel).toBe('TCP middlewares')
})
it('should handle complex query strings with special characters', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/services?filter=%40test%23special'),
})
expect(result.current.returnToLabel).toBe('HTTP services')
})
it('should capitalize first letter of label override', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/resource/routers/router-1'),
})
// Verify the label starts with uppercase
expect(result.current.returnToLabel?.charAt(0)).toBe('R')
})
})

View File

@@ -0,0 +1,119 @@
import qs from 'query-string'
import { useMemo } from 'react'
import { useHref, useLocation, useSearchParams } from 'react-router-dom'
import { capitalizeFirstLetter } from '../utils/string'
type UseGetUrlWithReturnTo = (href: string, initialReturnTo?: string) => string
export const useGetUrlWithReturnTo: UseGetUrlWithReturnTo = (href, initialReturnTo) => {
const location = useLocation()
const currentPath = location.pathname + location.search
const url = useMemo(() => {
if (href) {
return qs.stringifyUrl({ url: href, query: { returnTo: initialReturnTo ?? currentPath } })
}
return href
}, [currentPath, href, initialReturnTo])
return url
}
export const useHrefWithReturnTo = (href: string, returnTo?: string): string => {
const urlWithReturnTo = useGetUrlWithReturnTo(href, returnTo)
return useHref(urlWithReturnTo)
}
const RETURN_TO_LABEL_OVERRIDES_SINGULAR: Record<string, Record<string, string>> = {
http: {
routers: 'HTTP router',
services: 'HTTP service',
middlewares: 'HTTP middleware',
},
tcp: {
routers: 'TCP router',
services: 'TCP service',
middlewares: 'TCP middleware',
},
udp: {
routers: 'UDP router',
services: 'TCP service',
},
}
const RETURN_TO_LABEL_OVERRIDES_PLURAL: Record<string, Record<string, string>> = {
http: {
routers: 'HTTP routers',
services: 'HTTP services',
middlewares: 'HTTP middlewares',
},
tcp: {
routers: 'TCP routers',
services: 'TCP services',
middlewares: 'TCP middlewares',
},
udp: {
routers: 'UDP routers',
services: 'TCP services',
},
}
type UseRouterReturnTo = (initialReturnTo?: string) => {
returnTo: string | null
returnToLabel: string | null
}
const getCleanPath = (path: string) => {
if (!path) return ''
return path.split('?')[0]
}
export const useRouterReturnTo: UseRouterReturnTo = () => {
const [searchParams] = useSearchParams()
const returnTo = useMemo(() => {
const queryReturnTo = searchParams.get('returnTo')
return queryReturnTo || null
}, [searchParams])
const returnToHref = useHref(returnTo || '')
const returnToLabel = useMemo(() => {
if (!returnTo) {
return null
}
const returnToArr = returnTo.split('/')
const [, path, subpath, id] = returnToArr
// Strip query params from path, if any
const cleanPath = getCleanPath(path)
const cleanSubpath = getCleanPath(subpath)
// Malformed returnTo (e.g., just '/' or empty path)
if (!cleanPath) {
return 'Back'
}
const fallbackLabel = `${capitalizeFirstLetter(cleanPath)}${cleanSubpath ? ` ${cleanSubpath}` : ''}`
const labelArray = id ? RETURN_TO_LABEL_OVERRIDES_SINGULAR : RETURN_TO_LABEL_OVERRIDES_PLURAL
const labelOverride =
labelArray[cleanPath]?.[cleanSubpath] ??
(typeof labelArray[cleanPath] === 'string' ? labelArray[cleanPath] : fallbackLabel)
return capitalizeFirstLetter(labelOverride)
}, [returnTo])
return useMemo(
() => ({
returnTo: returnTo ? returnToHref : null,
returnToLabel,
}),
[returnTo, returnToHref, returnToLabel],
)
}

View File

@@ -4,10 +4,10 @@ import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import Container from './Container'
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from './Navigation'
import { ToastPool } from 'components/ToastPool'
import { ToastProvider } from 'contexts/toasts'
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from 'layout/navigation'
export const LIGHT_PRIMARY_COLOR = '#217F97'
export const DARK_PRIMARY_COLOR = '#2AA2C1'

View File

@@ -1,10 +1,10 @@
import { SideNav, TopNav } from './Navigation'
import { SideNav, TopNav } from '.'
import { renderWithProviders } from 'utils/test'
describe('Navigation', () => {
it('should render the side navigation bar', async () => {
const { container } = renderWithProviders(<SideNav isExpanded={false} onSidePanelToggle={() => {}} />)
const { container } = renderWithProviders(<SideNav isExpanded onSidePanelToggle={() => {}} />)
expect(container.innerHTML).toContain('HTTP')
expect(container.innerHTML).toContain('TCP')

View File

@@ -1,18 +1,9 @@
import {
Badge,
Box,
Button,
CSS,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
elevationVariants,
Flex,
Link,
NavigationLink,
SidePanel,
styled,
@@ -22,27 +13,23 @@ import {
} from '@traefiklabs/faency'
import { useContext, useEffect, useMemo, useState } from 'react'
import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs'
import { FiBookOpen, FiGithub, FiHelpCircle } from 'react-icons/fi'
import { matchPath, useHref } from 'react-router'
import { useLocation } from 'react-router-dom'
import { useWindowSize } from 'usehooks-ts'
import Container from './Container'
import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from './Page'
import Container from '../Container'
import { LAPTOP_BP } from '.'
import IconButton from 'components/buttons/IconButton'
import Logo from 'components/icons/Logo'
import { PluginsIcon } from 'components/icons/PluginsIcon'
import ThemeSwitcher from 'components/ThemeSwitcher'
import TooltipText from 'components/TooltipText'
import { VersionContext } from 'contexts/version'
import useTotals from 'hooks/use-overview-totals'
import { useIsDarkMode } from 'hooks/use-theme'
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
import { Route, ROUTES } from 'routes'
export const LAPTOP_BP = 1025
const NavigationDrawer = styled(Flex, {
width: '100%',
maxWidth: '100%',
@@ -61,11 +48,13 @@ export const BasicNavigationItem = ({
count,
isSmallScreen,
isExpanded,
onSidePanelToggle,
}: {
route: Route
count?: number
isSmallScreen: boolean
isExpanded: boolean
onSidePanelToggle: (isOpen: boolean) => void
}) => {
const { pathname } = useLocation()
const href = useHref(route.path)
@@ -91,7 +80,13 @@ export const BasicNavigationItem = ({
}
return (
<NavigationLink active={isActiveRoute} startAdornment={route?.icon} css={{ whiteSpace: 'nowrap' }} href={href}>
<NavigationLink
onClick={isSmallScreen ? () => onSidePanelToggle(false) : undefined}
active={isActiveRoute}
startAdornment={route?.icon}
css={{ whiteSpace: 'nowrap' }}
href={href}
>
{route.label}
{!!count && (
<Badge variant={isActiveRoute ? 'green' : undefined} css={{ ml: '$2' }}>
@@ -113,7 +108,7 @@ export const SideBarPanel = ({
return (
<SidePanel
open={isOpen && windowSize.width < LAPTOP_BP}
open={isOpen && windowSize.width <= LAPTOP_BP}
onOpenChange={onOpenChange}
side="left"
css={{ width: 264, p: 0 }}
@@ -145,8 +140,10 @@ export const SideNav = ({
const [isSmallScreen, setIsSmallScreen] = useState(false)
useEffect(() => {
setIsSmallScreen(isResponsive && windowSize.width < LAPTOP_BP)
}, [isExpanded, isResponsive, windowSize.width])
setIsSmallScreen(windowSize.width <= LAPTOP_BP)
}, [isExpanded, windowSize.width])
const isSmallAndResponsive = useMemo(() => isSmallScreen && isResponsive, [isResponsive, isSmallScreen])
const totalValueByPath = useMemo<{ [key: string]: number }>(
() => ({
@@ -164,7 +161,7 @@ export const SideNav = ({
return (
<NavigationDrawer
data-collapsed={isExpanded && isResponsive && isSmallScreen}
data-collapsed={isExpanded && isSmallAndResponsive}
css={{
width: 264,
height: '100vh',
@@ -224,12 +221,11 @@ export const SideNav = ({
? { mt: '$4', px: 0, justifyContent: 'center' }
: undefined,
}}
href="https://github.com/traefik/traefik/"
target="_blank"
href={useHref('/')}
data-testid="proxy-main-nav"
>
<Logo height={isSmallScreen ? 36 : 56} isSmallScreen={isSmallScreen} />
{!!version && !isSmallScreen && (
<Logo height={isSmallAndResponsive ? 36 : 56} isSmallScreen={isSmallAndResponsive} />
{!!version && !isSmallAndResponsive && (
<TooltipText text={version} css={{ maxWidth: 50, fontWeight: '$semiBold' }} isTruncated />
)}
</Flex>
@@ -268,6 +264,7 @@ export const SideNav = ({
count={totalValueByPath[item.path]}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
onSidePanelToggle={onSidePanelToggle}
/>
))}
</Flex>
@@ -286,113 +283,13 @@ export const SideNav = ({
</NavigationLink>
</Flex>
<ApimDemoNavMenu isResponsive={isResponsive} isSmallScreen={isSmallScreen} isExpanded={isExpanded} />
<ApimDemoNavMenu
isResponsive={isResponsive}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
onSidePanelToggle={onSidePanelToggle}
/>
</Container>
</NavigationDrawer>
)
}
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
const { showHubButton, version } = useContext(VersionContext)
const isDarkMode = useIsDarkMode()
const parsedVersion = useMemo(() => {
if (!version) {
return 'master'
}
if (version === 'dev') {
return 'master'
}
const matches = version.match(/^(v?\d+\.\d+)/)
return matches ? 'v' + matches[1] : 'master'
}, [version])
useEffect(() => {
if (!showHubButton) {
setHasHubButtonComponent(false)
return
}
if (customElements.get('hub-button-app')) {
setHasHubButtonComponent(true)
return
}
const scripts: HTMLScriptElement[] = []
const createScript = (scriptSrc: string): HTMLScriptElement => {
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => {
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
}
scripts.push(script)
return script
}
// Source: https://github.com/traefik/traefiklabs-hub-button-app
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
return () => {
// Remove the scripts on unmount.
scripts.forEach((script) => {
if (script.parentNode) {
script.parentNode.removeChild(script)
}
})
}
}, [showHubButton])
return (
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6', ...css }}>
{!noHubButton && hasHubButtonComponent && (
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
<hub-button-app
key={`dark-mode-${isDarkMode}`}
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
/>
</Box>
)}
<ThemeSwitcher />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
<FiHelpCircle size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
<DropdownMenuGroup>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiBookOpen size={20} />
<Text>Documentation</Text>
</Flex>
</Link>
</DropdownMenuItem>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href="https://github.com/traefik/traefik/"
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiGithub size={20} />
<Text>Github Repository</Text>
</Flex>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
</Flex>
)
}

View File

@@ -0,0 +1,158 @@
import {
Box,
Button,
CSS,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
Flex,
Link,
Text,
Tooltip,
} from '@traefiklabs/faency'
import { useContext, useEffect, useMemo, useState } from 'react'
import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi'
import { useLocation } from 'react-router-dom'
import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from '../Page'
import ThemeSwitcher from 'components/ThemeSwitcher'
import { VersionContext } from 'contexts/version'
import { useRouterReturnTo } from 'hooks/use-href-with-return-to'
import { useIsDarkMode } from 'hooks/use-theme'
const TopNavBarBackLink = () => {
const { returnTo, returnToLabel } = useRouterReturnTo()
const { pathname } = useLocation()
if (!returnTo || pathname.includes('hub-dashboard')) return <Box />
return (
<Flex css={{ alignItems: 'center', gap: '$2' }}>
<Link href={returnTo}>
<Button as="div" ghost variant="secondary" css={{ boxShadow: 'none', p: 0, pr: '$2' }}>
<FiChevronLeft style={{ paddingRight: '4px' }} />
{returnToLabel || 'Back'}
</Button>
</Link>
</Flex>
)
}
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
const { showHubButton, version } = useContext(VersionContext)
const isDarkMode = useIsDarkMode()
const parsedVersion = useMemo(() => {
if (!version) {
return 'master'
}
if (version === 'dev') {
return 'master'
}
const matches = version.match(/^(v?\d+\.\d+)/)
return matches ? 'v' + matches[1] : 'master'
}, [version])
useEffect(() => {
if (!showHubButton) {
setHasHubButtonComponent(false)
return
}
if (customElements.get('hub-button-app')) {
setHasHubButtonComponent(true)
return
}
const scripts: HTMLScriptElement[] = []
const createScript = (scriptSrc: string): HTMLScriptElement => {
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => {
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
}
scripts.push(script)
return script
}
// Source: https://github.com/traefik/traefiklabs-hub-button-app
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
return () => {
// Remove the scripts on unmount.
scripts.forEach((script) => {
if (script.parentNode) {
script.parentNode.removeChild(script)
}
})
}
}, [showHubButton])
return (
<Flex as="nav" role="navigation" justify="space-between" align="center" css={{ mb: '$6', ...css }}>
<TopNavBarBackLink />
<Flex gap={2} align="center">
{!noHubButton && hasHubButtonComponent && (
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
<hub-button-app
key={`dark-mode-${isDarkMode}`}
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
/>
</Box>
)}
<Tooltip content="Sponsor" side="bottom">
<Link href="https://github.com/sponsors/traefik" target="_blank">
<Button as="div" ghost css={{ px: '$2', boxShadow: 'none' }}>
<FiHeart size={20} color="#db61a2" />
</Button>
</Link>
</Tooltip>
<ThemeSwitcher />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
<FiHelpCircle size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
<DropdownMenuGroup>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiBookOpen size={20} />
<Text>Documentation</Text>
</Flex>
</Link>
</DropdownMenuItem>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href="https://github.com/traefik/traefik/"
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiGithub size={20} />
<Text>Github Repository</Text>
</Flex>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
</Flex>
</Flex>
)
}

View File

@@ -0,0 +1,5 @@
// common breakpoint for large screen, cf. https://www.w3schools.com/howto/howto_css_media_query_breakpoints.asp
export const LAPTOP_BP = 1200
export * from './SideNavBar'
export * from './TopNavBar'

View File

@@ -7,7 +7,7 @@ import verifySignature from './workers/scriptVerification'
import { SpinnerLoader } from 'components/SpinnerLoader'
import { useIsDarkMode } from 'hooks/use-theme'
import { TopNav } from 'layout/Navigation'
import { TopNav } from 'layout/navigation'
const SCRIPT_URL = 'https://assets.traefik.io/hub-ui-demo.js'

View File

@@ -6,16 +6,18 @@ import { HubDemoContext } from './demoNavContext'
import { HubIcon } from './icons'
import Tooltip from 'components/Tooltip'
import { BasicNavigationItem, LAPTOP_BP } from 'layout/Navigation'
import { BasicNavigationItem, LAPTOP_BP } from 'layout/navigation'
const ApimDemoNavMenu = ({
isResponsive,
isSmallScreen,
isExpanded,
onSidePanelToggle,
}: {
isResponsive: boolean
isSmallScreen: boolean
isExpanded: boolean
onSidePanelToggle: (isOpen: boolean) => void
}) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const { navigationItems: hubDemoNavItems } = useContext(HubDemoContext)
@@ -38,7 +40,7 @@ const ApimDemoNavMenu = ({
transition: 'transform 0.3s ease-in-out',
}}
/>
{isSmallScreen ? (
{isSmallScreen && isResponsive ? (
<Tooltip label="Hub demo">
<Box css={{ ml: 4, color: '$navButtonText' }}>
<HubIcon width={20} />
@@ -74,6 +76,7 @@ const ApimDemoNavMenu = ({
route={route}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
onSidePanelToggle={onSidePanelToggle}
/>
))}
</Box>

File diff suppressed because it is too large Load Diff